diff --git a/Cargo.toml b/Cargo.toml index 2282934816..2a6a7de6af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = [ "examples/core/*", "examples/demos/*", "examples/comprehensive/*", - "examples/website/*", + # "examples/website/*", "website", ] resolver = "2" diff --git a/README.md b/README.md index d3c01b12ee..89c2742aac 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,13 @@ pub fn main() -> PerseusApp { PerseusApp::new() .template( Template::new("index") - .template(|cx| { + .view(|cx| { view! { cx, p { "Hello World!" } } }) - ) + .build() + ) } ``` diff --git a/bonnie.toml b/bonnie.toml index cae6247af5..247bc224df 100644 --- a/bonnie.toml +++ b/bonnie.toml @@ -160,19 +160,24 @@ test.cmd = [ "cargo test", # This will ignore Wasm tests # Run tests for each example "bonnie test example-all-integrations core basic --headless", - "bonnie test example-all-integrations core unreactive --headless", - "bonnie test example-all-integrations core i18n --headless", - "bonnie test example-all-integrations core plugins --headless", - "bonnie test example-all-integrations core state_generation --headless", + "bonnie test example-all-integrations core custom_server --headless", + "bonnie test example-all-integrations core error_views --headless", "bonnie test example-all-integrations core freezing_and_thawing --headless", "bonnie test example-all-integrations core global_state --headless", + "bonnie test example-all-integrations core helper_build_state --headless", + "bonnie test example-all-integrations core i18n --headless", "bonnie test example-all-integrations core idb_freezing --headless", + "bonnie test example-all-integrations core index_view --headless", + "bonnie test example-all-integrations core js_interop --headless", + "bonnie test example-all-integrations core plugins --headless", + # TODO core/preload "bonnie test example-all-integrations core router_state --headless", "bonnie test example-all-integrations core rx_state --headless", - "bonnie test example-all-integrations core index_view --headless", "bonnie test example-all-integrations core set_headers --headless", + "bonnie test example-all-integrations core state_generation --headless", "bonnie test example-all-integrations core static_content --headless", - "bonnie test example-all-integrations core custom_server --headless" + "bonnie test example-all-integrations core suspense --headless", + "bonnie test example-all-integrations core unreactive --headless", ] test.desc = "runs all tests headlessly (assumes geckodriver running in background)" test.subcommands.core.cmd = "cargo test" diff --git a/docs/next/en-US/SUMMARY.md b/docs/next/en-US/SUMMARY.md index d1b049b26b..02012326e8 100644 --- a/docs/next/en-US/SUMMARY.md +++ b/docs/next/en-US/SUMMARY.md @@ -2,11 +2,7 @@ - [Introduction](/docs/intro) - [What is Perseus?](/docs/what-is-perseus) -- [Getting Started](/docs/getting-started/intro) - - [Installation](/docs/getting-started/installation) - - [Your First App](/docs/getting-started/first-app) - [Core Principles](/docs/core-principles) -- [Your Second App](/docs/tutorials/second-app) # Reference diff --git a/docs/next/en-US/getting-started/first-app.md b/docs/next/en-US/getting-started/first-app.md deleted file mode 100644 index a89a1a8bbc..0000000000 --- a/docs/next/en-US/getting-started/first-app.md +++ /dev/null @@ -1,75 +0,0 @@ -# Your First App - -With a basic app scaffold all set up, it's time to get into the nitty-gritty of building a Perseus app. Somewhat humorously, the hardest part to wrap your head around as a beginner is probably going to be the `Cargo.toml` we're about to set up. It should look something like the following. - -```toml -{{#include ../../../examples/comprehensive/tiny/Cargo.toml.example}} -``` - -
-Excuse me?? - -The first section is still pretty straightforward, just defining the name and version of your app's package. The line after that, `edition = "2021"`, tells Rust to use a specific version of itself. There's also a 2018 version and a 2015 version, though Perseus won't work with either of those, as it needs some features only introduced in 2021. That version also includes some comfort features that will make your life easier at times. - -Now we'll address the interesting dependencies setup. Essentially, we've got three dependency sections. The reason for this is because Perseus runs partly on a server, and partially in a browser. The former is called the *engine*, which is responsible for building and serving your app. The latter is just called the browser, which is where Perseus makes your app work for your users. - -These two environments couldn't really be more different, and, while Perseus tries to minimize the complexities of managing both from your perspective, there are *many* Rust packages that won't run in the browser yet. By having separate dependency sections for each environment, we can decide explicitly which packages we want to be available where. - -The first section is the usual one, pulling in dependencies that we want everywhere. Both `perseus` and `sycamore` are needed in the browser and on the server-side, so we put them here. Most of the packages you bring in yourself will go here too. As a general rule of thumb, put stuff here unless you're getting errors or trying to optimize your app (which we have a whole section on). - -The second is `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]`, which looks scary, but is actually pretty simple when you think about it. It defines the `dependencies` section only on the `target` (operating system) that matches the condition `cfg(not(target_arch = "wasm32"))` --- the target that's not `wasm32`, which is Rust's way of talking about the browser. This section contains engine-only dependencies. In other words, the code that runs in the browser will have no clue these even exist. We put two things in here: `tokio` and `perseus-warp`. The first is an asynchronous runtime that Perseus uses in the background (this means we can do stuff like compile three parts of your app at the same time, which speeds up builds). The second is one of those integration crates we were talking about earlier, with the `dflt-server` feature enabled, which makes it expose an extra function that just makes us a server that we don't have to think about. Unless you're writing custom API routes, this is all you need. - -The third section is exactly the same as the previous, just without that `not(...)`, so this one defines dependencies that we use in the browser only. We've put `wasm-bindgen` here, which we could compile on the server, but it would be a little pointless, since Perseus only uses it behind the scenes in making your app work in the browser. (This is needed for a macro that a Perseus macro defines, which is why you have to import it yourself.) - -
- -Next, we can get on with the app's actual code! Head over to `src/main.rs` and put the following inside: - -```rust -{{#include ../../../examples/comprehensive/tiny/src/main.rs}} -``` - -This is your entire first app! Note that most Perseus apps won't actually look like this, we've condensed everything into 17 lines of code for simplicity. - -
-So that means something, does it? - -We've started off by importing the Perseus and Sycamore preludes, which give us everything we need to build an app! We'll talk about each import as we get to it. The really important thing here is the `main()` function, which is annotated with the `#[perseus::main()]` *proc macro* (these are nifty things in Rust that let you define something, like a function, and then let the macro modify it). This macro isn't necessary, but it's very good for small apps, because there's actually fair bit of stuff happening behind the scenes here. - -We also give that macro an argument, `perseus_warp::dflt_server`. You should change this to whatever integration you're using (we set up `perseus_warp` earlier). Every integration has a feature called `dflt-server` (which we enabled earlier in `Cargo.toml`) that exposes a function called `dflt_server` (notice how the packages use hyphens and the code uses underscores --- this is a Rust convention). - -As you might have inferred, the argument we provide to the `#[perseus::main()]` macro is the function it will use to create a server for our app! You can provide something like `dflt_server` here if you don't want to think about that much more, or you can define an expansive API and use that here instead! (Note that there's actually a much better way to do this, which is addressed much later on.) - -This function also takes a *generic*, or *type parameter*, called `G`. We use this to return a [`PerseusApp`](=type.PerseusApp@perseus) (which is the construct that contains all the information about our app) that uses `G`. This is essentially saying that we want to return a `PerseusApp` for something that implements the `Html` trait, which we imported earlier. This is Sycamore's way of expressing that this function can either return something designed for the browser, or for the engine. Specifically, the engine uses `SsrNode` (server-side rendering), and the browser uses `DomNode`/`HydrateNode`. Don't worry though, you don't need to understand these distinctions just yet. - -The body of the function is where the magic happens: we define a new `PerseusApp` with our one template and some error pages. The template is called `index`, which is a special name that means it will be hosted at the index of our site --- it will be the landing page. The code for that template is a `view! { .. }`, which comes from Sycamore, and it's how we write things that the user can see. If you've used HTML before, this is the Rust version of that. It might look a bit daunting at first, but most people tend to warm to it fairly well after using it a little. - -All this `view! { .. }` defines is a `p`, which is equivalent to the HTML `

`, a paragraph element. This is where we can put text for our site. The contents are the universal phrase, `Hello World!`. - -You might be scratching your head about that `cx` though. Understandable. This is the *reactive scope* of the view, which is something complicated that you would need to understand much more about if you were using normal Sycamore. In Perseus, all you really need to know for the basics is that this is a thing that you need to give to every `view! { .. }`, and that your templates always take it as an argument. If you want to know what this actually does, you can read more about it [here](https://sycamore-rs.netlify.app/docs/basics/reactivity). - -The last thing to note is the `ErrorPages`, which are an innovation of Perseus that force you to write fallback pages for situations like the user going to a page that doesn't exist (the infamous 404 error). You could leave these out in development, but when you go to production, Perseus will scream at you. The error pages we've defined here are dead simple: we're just using the universal fallback provided to `ErrorPages::new()`, which is used for everything, unless you provide specific error pages for errors like 404, 500, etc. This fallback page is told the URL the error occurred on, the HTTP status code, and the error itself. - -
- -With that all out of the way, add the following to `.gitignore`: - -```gitignore -dist/ -``` - -That just tells Git not to pay any attention to the build artifacts that Perseus is about to create. Now run this command: - -```sh -perseus serve -w -``` - -Because this is the first time building your app, Cargo has to pull in a whole lot of stuff behind the scenes, so now would be a good time to fix yourself a beverage. Once it's done, you can see your app at , and you should be greeted pleasantly by your app! If you want to check out the error pages, go to , or any other page that doesn't exist. - -Now, try updating that `Hello World!` message to be a little more like the first of its kind: `Hello, world!` Once you save the file, the CLI will immediately get to work rebuilding your app, and your browser will reload automatically when it's done! - -*Note: if you're finding the build process really slow, or if you're on older hardware, you should try switching to Rust's [nightly channel](https://doc.rust-lang.org/book/appendix-07-nightly-rust.html#rustup-and-the-role-of-rust-nightly) for a faster compiler experience.* - -Now stop that command with `Ctrl+C` and run `perseus deploy` instead. This will take a very long time, but it will produce a `pkg/` directory that you could put on a real-world server, and it would be completely ready to serve your brand-new app! Because this app is so simple, you could even use `perseus deploy -e` instead to just produce a bunch of flat files that you could host from anywhere without needing a proper server. - -All this has just scratched the surface of what's possible with Perseus, and there's so much more to learn! The next big things are about understanding some of the core principles behind Perseus, which should help you to understand why any of what you just did actually worked. diff --git a/docs/next/en-US/getting-started/installation.md b/docs/next/en-US/getting-started/installation.md deleted file mode 100644 index 415ae41b2d..0000000000 --- a/docs/next/en-US/getting-started/installation.md +++ /dev/null @@ -1,32 +0,0 @@ -# Installing Perseus - -Perseus comes in a few parts: there's the core `perseus` crate, there's a server integration crate (like `perseus-warp` or `perseus-actix-web`), and then there's the Perseus CLI. - -If you're unfamiliar with Rust's package management system, no problem, *crates* are packages that other people create so you can use their code easily. For example, the `perseus` crate exposes all the functions you need to build a Perseus app. - -You also might be wondering why there are separate server integration crates. We could've bundled everything together in the `perseus` crate, but we wanted to give you a choice of which server integration to use. There are quite a few in the Rust ecosystem at the moment, and, especially if you're migrating an existing app from another system, you might already have a whole API defined in an Actix Web server, or an Axum one, or a Warp one. So, there's a Perseus integration crate for each of those, which you can easily plug an existing API into! Note that there's basically no difference between the APIs of integration crates, and that they're all fairly similar in speed (though Actix Web is usually the fastest). - -Finally, the Perseus CLI is just a tool to make your life exceedingly easy when building web apps. You just run `perseus serve -w` to run your app and `perseus deploy` to output a folder of stuff to send to production! While you *could* use Perseus without the CLI, that approach isn't well-documented, and you'll probably end up in a tangle. The CLI makes things much easier, performing parallel builds and moving files around so you don't have to. - -## Get on with it! - -Alright, that's enough theory! Assuming you've already got `cargo` (Rust's package manager installed), you can install the Perseus CLI like so: - -```sh -cargo install perseus-cli -``` - -This will take a few minutes to download and compile everything. (Note: if you don't have Rust or Cargo yet, see [here](https://rust-lang.org/tools/install) for installation instructions.) - -Next up, you should set up your new app like so: - -```sh -perseus new my-app -cd my-app -``` - -This will create a new directory called `my-app/` in your current directory, setting it up for a new Perseus project. If you want to move this directory somewhere else, you can do that as usual, everything's self-contained. - -Note that any `perseus` command will also install the `wasm32-unknown-unknown` target if you have `rustup` available to do so, since you need it for developing with Perseus. Also note that the Perseus CLI used to have some other dependencies, namely `wasm-pack`, but these are now all inbuilt, and will be automatically installed and managed for you! - -You can run `perseus serve -w` now if you want to see the placeholder app, or you can move ahead to the next section to get your feet wet. diff --git a/docs/next/en-US/getting-started/intro.md b/docs/next/en-US/getting-started/intro.md deleted file mode 100644 index 4841c0239a..0000000000 --- a/docs/next/en-US/getting-started/intro.md +++ /dev/null @@ -1,3 +0,0 @@ -# Getting Started - -This section will walk you through building your first app with Perseus. Even if you don't follow along yourself, this tutorial is still a great way to get to know the basics of Perseus, and how its ergonomics compare to other frameworks. diff --git a/docs/next/en-US/intro.md b/docs/next/en-US/intro.md index a65c311649..05244df4bc 100644 --- a/docs/next/en-US/intro.md +++ b/docs/next/en-US/intro.md @@ -2,6 +2,8 @@ [Home][repo] • [Crate Page][crate] • [API Documentation][docs] • [Contributing][contrib] +**WARNING:** These docs are under heavy construction right now, in preparation for the release of Perseus v0.4.0, which will be, by far, the most powerful version of Perseus yet! A lot has changed though, so we *highly* recommend sticking to the v0.3.4+ docs if you're using v0.3.x, or the v0.4.x docs if you're still on one of the beta versions up to beta 11. The `next` docs (these ones) are highly incomplete, probably full of errors and typos, and have not yet been thoroughly checked. You have been warned! + Welcome to the Perseus documentation! Here, you'll find guides on how to use Perseus, as well as documentation for specific features and plenty of examples! Note that every code snippet in these docs comes from something in the [examples](https://github.com/arctic-hen7/perseus/tree/main/examples), where you can get context from real-world code. If you like Perseus, please consider giving us a star [on GitHub](https://github.com/arctic-hen7/perseus)! diff --git a/docs/next/en-US/reference/faq.md b/docs/next/en-US/reference/faq.md index 4b6458d4bb..27d3fa0596 100644 --- a/docs/next/en-US/reference/faq.md +++ b/docs/next/en-US/reference/faq.md @@ -42,3 +42,17 @@ The best solution, however, is to switch to nightly Rust (`rustup override set n ## I'm getting really weird errors with a page's ``... Alright, this can mean about a million things. There is one that could be known to be Perseus' fault though: if you go to a page in your app, then reload it, then go to another page, and then navigate *back* to the original page (using a link inside your app, *not* your browser's back button), and there are problems with the `` that weren't there before, then you should disable the `cache-initial-load` feature on Perseus, since Perseus is having problems figuring out how your `` works. Typically, a delimiter `` is added to the end of the ``, but if you're using a plugin that's adding anything essential after this, that will be lost on transition to the new page. Any advanced manipulation of the `` at runtime could also cause this. Note that disabling this feature (which is on by default) will prevent caching of the first page the user loads, and it will have to be re-requested if they go back to it, which incurs the penalty of a network request. + +## I'm getting a 'mismatched render backends' error + +This is a very rare kind of error that Perseus will emit if it knows that running your app in its current state will cause undefined behavior: it's a safeguard against far worse things happening. if you're using the reference pattern of managing your templates and/or capsules, where you define them in `lazy_static!`s, and then bring those into `.template_ref()`/`.capsule_ref()`, this problem is almost certainly caused by your using the incorrect *render backend generic*. In those statics, you have to specify a concrete value for that `G: Html` you see floating around the place. You might have chosen `DomNode`, or `SsrNode`, or maybe even `HydrateNode`, but each of these is only valid sometimes! Perseus internally knows when it uses each one, and it provides a clever little type alias that can handle all this for you: `PerseusNodeType`. If you use that, this error shoudl go away, adn your app should work perfectly! + +Alternately, this error can occur if you try to do something very inadvisable, like putting a widget in a `view!` that you try to `render_to_string` on the browser-side. In fact, any attempt to render to a string in the browser that uses widgets is almost certain to trigger this exact error. This is because `PerseusNodeType` automatically resolves to `DomNode`/`HydrateNode` (depending on whether or not you've enabled the `hydrate` feature) on the browser-side, because Perseus doesn't need to do any server-side rendering there (unsurprisingly). That means, when you bring in a widget that's defined as a `lazy_static!` using `PerseusNodeType`, your `View` might be a `View`, but the `MY_WIDGET.widget()` function will take that `SsrNode`, hold it for a moment, and check the type of itself, which it will find to be `PerseusNodeType`. Since `DomNode != SsrNode` and `HydrateNode != SsrNode`, it will find that you're trying to use a browser-side widget in a server-side rendered view, which is a type mismatch. Normally, this sort of thing could be caught by Rust at compilation-time, but Perseus uses some transmutes internally to make it safe to use `PerseusNodeType`, as long as it lines up with the actual type of the `View` being rendered. if you try to server-side render in the browser though, the types don't line up, and Perseus has the choice of either panicking or causing undefined behavior. To maintain safety, it panics. + +Note that this doesn't mean it's actually impossible to server-side render a widget on the browser-side, you can use the functional pattern to do this easily. Rather than using `MY_CAPSULE.widget()`, just use `crate::path::to::my::widget::get_capsule().widget()`, because `get_capsule()` is generic over `G: Html` meaning it will just work with Rust's normal typing system. + +If you're still getting this error, and none of these solutions make sense with what you're doing, then you've possibly encountered a rather serious Perseus bug, which we'd like to know about so we can fix it! Please report it [on GitHub](https://github.com/framesurge/perseus/issues/new/choose). + +## Problem binding to `http://localhost:3100` + +This means another instance of Persues is already running. The reason this talks about rather than port 8080 is because 3100 is where the live reload server runs by default. diff --git a/docs/next/en-US/tutorials/second-app.md b/docs/next/en-US/tutorials/second-app.md deleted file mode 100644 index 04a291c6cc..0000000000 --- a/docs/next/en-US/tutorials/second-app.md +++ /dev/null @@ -1,168 +0,0 @@ -# Your Second App - -Now it's time to build a more realistic app with Perseus, one that takes advantage of the state platform and uses a structure more similar to one you'd see in a real Perseus app. All the code for this tutorial is available [here](https://github.com/arctic-hen7/perseus/tree/main/examples/core/basic). - -Note that this tutorial assumes you've already installed Rust and the Perseus CLI as per [these instructions](:getting-started/installation). - -## Setup - -We can create a new Perseus project by going to some directory and running `perseus new my-app`, which will create a folder called `my-app/` and set it up for Perseus. Then, you can pop in there and make sure `Cargo.toml` looks like the following: - -```toml -{{#include ../../../examples/core/basic/Cargo.toml.example}} -``` - -This is almost identical to the [first app](:getting-started/first-app) we built, so we'll skip over further explanation here. Recall though that this structure of declaring engine-side and browser-side dependencies separately is a fairly standard pattern in Perseus. - -Next, create some files and folders so that your project tree looks like this: - -``` -├── Cargo.toml -└── src - ├── error_pages.rs - ├── lib.rs - └── templates - ├── about.rs - ├── index.rs - └── mod.rs -``` - -Great, now you've got your basic directory setup for a Perseus project! For a quick foreshadowing, we'll be putting our app declaration in `src/lib.rs`, our error pages (for 404 etc.) in `src/error_pages.rs`, adn each of the files in `templates/` will correspond to a template (which could generate as many pages as it wants, see [here](:core-principles) for an explanation of this). - -Finally, add the following to the top of `src/lib.rs` so that Cargo knows about this structure: - -```rust -mod error_pages; -mod templates; -``` - -And then make `src/templates/mod.rs` look like the following to declare the files in there to Cargo: - -```rust -{{#include ../../../examples/core/basic/src/templates/mod.rs}} -``` - -The reason these are `pub mod`s is so that we can access them from `lib.rs` easily. - -## Index template - -Let's jump right into the code of this app! We'll start with the index template, which will render the landing page of our site. The code for this is accordingly in `src/templates/index.rs`. - -In `src/templates/index.rs`, dump the following code (replacing the auto-generated code): - -```rust -{{#include ../../../examples/core/basic/src/templates/index.rs}} -``` - -This is much more complex than our first app, so let's dive into explanation. Note that the imports at the top of this file will be explained as we go. - -The first thing then is `IndexPageState`, which is our first usage of Perseus' state platform. As explained [here](:core-principles), a page in Perseus is produced from a template and some state. In this template, we'll only be rendering one template, but it will use some state to demonstrate how we can execute arbitrary code when we build our app to create pages. In this case, our state is dead simple, containing only one property, `greeting`, which is a `String`. - -Importantly, we've annotated that with `#[perseus::make_rx(IndexPageStateRx)]`, which will create a version of this `struct` that uses Sycamore's `Signal`s: a reactive version. If you're unfamiliar with Sycamore's reactivity system, you should read [this](https://sycamore-rs.netlify.app/docs/basics/reactivity) quickly before continuing. - -Next, we create a function called `index_page`, which we annotate with `#[perseus::template]`. That macro is used for declaring templates, and you can think of it like black box that makes things work. - -
-Details?? - -What that macro actually does depends on the complexity of your template, but the core purpose is to make sure it gets the right state. Internally, Perseus passes around all state as serialized `String`s, since it needs to be sent over the network from the server. This macro performs deserialization for you, and registers the state with the app-wide state management system if it's the first load of it. If not, it will restore previous state, meaning, for example, that user inputs can retain their content even if the user goes to three other pages in your app before returning, with no extra code from you. The workings of these macros aren't too complex, but they are extremely unergonomic. - -If you really viscerally hate macros though, then you *could* implement the under-the-hood stuff manually based on [this file](), but we seriously wouldn't recommend it. Also, that code could change at any time, which means any update could be a breaking change for you. - -*Note: these macros are progressively becoming less and less important to Perseus. Eventually, we hope to reduce them to the absolute minimum necessary for functionality.* - -
- -This function takes two arguments: a Sycamore reactive scope and the reactive version of the state, which both share the same lifetime `'a`. Don't worry though, we won't have to worry about these lifetimes most of the time, Sycamore is very well-designed to make them stay out of our way! They're just there to make things much more ergonomic and speedy. (In older version,s you had to `.clone()` *everything*.) - -Finally, we produce a Sycamore [`View`](=struct.View@sycamore), which will render content to the user's browser. Notably, this function is generic over a type parameter `G: Html`, which we use to make sure this code can be run on both the engine-side and the browser-side. - -
-Wait up, why are my templates being rendered on the engine-side? - -To improve performance *dramatically*, Perseus renders all pages on the engine-side before your app ever gets to users, creating fully-built HTML that can be sent down at a moment's notice, meaning users see pages quickly, and then they become interactive a moment later. Generally, this is agreed to be much better than users having to wait potentially several seconds to see anything on your site at all. - -As a result of this, the code in any template function must be able to run on both the browser-side and the server-side. But, you can always use `#[cfg(target_arch = "wasm32")]` to gate browser-only code, or `#[cfg(not(target_arch = "wasm32"))]` to gate engine-only code. - -
- -Inside this function, we use Sycamore's [`view!`](=macro.view@sycamore) macro to a create a view for the user, which will be displayed in their browser. We provide the reactive scope `cx`, and then we have just two items we're rendering. The second is a simple link to `about`, which is the same as `/about`, but without the absolutism that the route has to be at the top-level (instead, it will be relative to the rest of the site, which lets you serve your entire app inside another website trivially --- it's exactly what's done on this website!). - -The first element is a paragraph that contains some dynamic content. Specifically, the value of that `greeting` property in our state. Notably, we're calling `.get()` on that, because, remember, we're using the reactive version, so it's not a `String` anymore, it's a `&'a Signal`! Again, you don't need to worry about the lifetimes, Sycamore makes all that seamless for you. - -Notably, we could actually change this value at runtime if we wanted by calling `.set()`, but we won't do that in this example. - -The next function we define is `get_template()`, which is fairly straightforward. It just declares a [`Template`](=struct.Template@perseus) with the necessary properties. Specifically, we define the function that actually renders the template as `index_page`, and the other two we'll get to now. - -The first of those is the `head()` function, which is annotated as `#[perseus::head]` (which has similar responsibilities to `#[perseus::template]`). In HTML, the language for creating views on the web, there are two main components to every page: the `` and the ``, the latter of which defines certain metadata, like the title, and any stylesheets you need, for example. If `index_page()` creates the body, then `head()` creates the head in this example. Notably, because the head is rendered only ahead of time, it can't be reactive. For that reason, it's passed the unreactive version of the state, and, rather than being generic over `Html`, it uses [`SsrNode`](=struct.SsrNode@perseus), which is specialized for the engine-side. - -Because this function will only ever run on the engine-side, `#[perseus::head]` implies a target-gate to the engine (i.e. `#[cfg(not(target_arch = "wasm32"))]` is implicit). This means you can use engine-side dependencies here without any extra gating. - -Finally, `get_build_state()` is responsible for generating an instance of `IndexPageState` that the template will be rendered with ahead of time on the engine-side. In this example, this logic is very simple, just generating a static `greeting`, but, in more complex apps, this might fetch information from a database, or it could run more complex computations. - -For example, this very website uses build-time state generation to fetch the content for each of these docs pages from Markdown, rendering then to HTML, making the experience of both writing and viewing these docs as smooth as possible! - -Importantly, that function takes two parameters: the path of the page (only relevant if you're using *build paths* too) and the locale (only relevant if you're using internationalization). Crucially, we return a [`RenderFnResultWithCause`](=struct.RenderFnResultWithCause@perseus), which is basically a glorified `Result` that lets you return any error type you want. But, we need to do one more thing if we get an error in state generation: we need to know who's responsible. You're probably familiar with the 404 HTTP status code, meaning the page wasn't found, but there are actually dozens of these, all with different meanings (like 418, which indicates the server is a teapot incapable of brewing coffee). The 4xx codes are for when the client caused the problems, and the 5xx codes are for when the server caused the problem. For the Perseus server to know which of these to send, it needs to know who was responsible, which `RenderFnResultWithCause` lets you declare. For an example of how to return errors from here like this, see [here](). - -
-But we're generating on the engine-side... - -It may seem like the client could never be responsible, since we're generating state at build time. That's true, unless you're using *incremental generation*, which is another state generation strategy that means functions like `get_build_state()` could be executed on the engine-side while the server is running in production, and the `path` parameter can be arbitrary. In these cases, the client can most certainly cause an error. - -If none of that makes sense, don't worry, you can learn more about it [here](). - -
- -## About template - -With that done, we can build the second template of this app, which is much simpler! Add the following to `src/templates/about.rs`: - -```rust -{{#include ../../../examples/core/basic/src/templates/about.rs}} -``` - -This is basically a simpler version of the index template, with no state, and this template only defines a simple view and some metadata in the head. - -Importantly, this illustrates that templates that don't take state don't have to have a second argument for their nonexistent state, the `#[perseus::template]` macro is smart enough to handle that (and even a third argument for global state). - -## Error pages - -Before we tie everything together, we've got to handle errors in this app! If the user goes to a page that doesn't exist right now, they'll be greeted with a stock error page designed for development debugging and fast iteration. In production, we won't even be allowed to build our app unless we set up some error handling. - -Add the following to `src/error_pages.rs`: - -```rust -{{#include ../../../examples/core/basic/src/error_pages.rs}} -``` - -This is a very simplistic error page setup, but it illustrates pretty well what error pages actually are in Perseus. Essentially, you define a new [`ErrorPages`](=struct.ErrorPages@perseus) instance that's again generic over `Html` so it can work on the engine-side and the browser-side. In that `::new()` function, you need to provide a fallback page, because you're unlikely to provide a different error page for every possible HTTP status code. If one occurs that you haven't explicitly handled for, this fallback page will be used. Then, we use `.add_page()` to add another page for the 404 HTTP status code (page not found). - -Notably, an error page is defined with a closure that takes four arguments: a reactive scope, the URL the user was on when the error occurred (which they'll stay on while the error page is displayed), the HTTP status code, teh actual `String` error message, and a translator (but we aren't using i18n, so we don't need this). - -## Tying it all together - -Now, we can bring everything together in `src/main.rs`: - -```rust -{{#include ../../../examples/core/basic/src/main.rs}} -``` - -**Important:** replace `perseus_integration` here with `perseus_warp`! We use `perseus_integration` as an internal glue crate, and all code in these docs is sourced directly from the examples. - -This is quite similar to the first app we built, though with a few more complexities. As in that app, we declare a `main()` function annotated with `#[perseus::main(...)]` to declare the entrypoint of our app. In there, we define the function that will spin up our server, here just using the `dflt_server` of our chosen integration. In the `Cargo.toml` above, we used `perseus_warp`, but you could trivially use any integration you like (or whatever works with your existing servers, which Perseus can extend). - -Then, on our [`PerseusApp`](=struct.PerseusApp@perseus), we define the two templates, and our error pages. Simple as that! - -## Running it - -```shell -perseus serve -w -``` - -This will compile your whole app (which might take a while for the first time), and then serve it at ! If you take a look there, you should be greeted with *Hello World!* and a link to the about page, which should take you there without causing the browser to load a new page. This demonstrates how Perseus internally switches pages out with minimal requests to the server, using less bandwidth and enabling faster page transitions. - -Now, try changing that *Hello World!* greeting to be the more historically accurate *Hello, world!* in `src/templates/index.rs`, and watch as the CLI automatically recompiles your app and reloads the browser so you can see your changes! - -*Note: this simple change will probably take a fair while to recompile for. See [here](:reference/compilation-times) for how to optimize this.* - -Finally, try running `perseus deploy`, and you'll get a `pkg/` folder at the root of your project with a `server` executable, which you can run to serve your app in production! With any Perseus app, that `pkg/` folder can be sent to a server and hosted live! diff --git a/examples/.base/src/error_pages.rs b/examples/.base/src/error_pages.rs deleted file mode 100644 index 55c8c8b6f1..0000000000 --- a/examples/.base/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages() -> ErrorPages { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/.base/src/main.rs b/examples/.base/src/main.rs index 87ed16df4a..9960c05cf4 100644 --- a/examples/.base/src/main.rs +++ b/examples/.base/src/main.rs @@ -1,11 +1,10 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main() -> PerseusApp { PerseusApp::new() .template(crate::templates::index::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_pages(ErrorViews::unlocalized_development_default()) } diff --git a/examples/.base/src/templates/index.rs b/examples/.base/src/templates/index.rs index f0a103783b..b6cf33245a 100644 --- a/examples/.base/src/templates/index.rs +++ b/examples/.base/src/templates/index.rs @@ -7,6 +7,7 @@ fn index_page(cx: Scope) -> View { } } +#[engine_only_fn] fn head(cx: Scope) -> View { view! { cx, title { "Index Page" } @@ -14,5 +15,8 @@ fn head(cx: Scope) -> View { } pub fn get_template() -> Template { - Template::new("index").template(index_page).head(head) + Template::new("index") + .view(index_page) + .head(head) + .build() } diff --git a/examples/README.md b/examples/README.md index efefb53c79..ea050d5be7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,4 +8,10 @@ Each of the examples here are fully self-contained Perseus apps, though they use If any of these examples don't work, please [open an issue](https://github.com/arctic-hen7/perseus/issues/choose) and let us know! +The `website/` directory contains the examples you see on the front page of the Perseus website, [here](https://framesurge.sh/perseus/en-US). These should be kept as concise as possible, but it doesn't matter if they're updated on `main` or in a PR for code that hasn't been published yet, since the website gets them from the `stable` branch. That way, those examples will always be for the latest published version of Perseus (even if it's a beta version). + *Note: by default, all examples are assumed to use the `perseus-integration` helper crate, which allows testing them with all integrations. If this is not the case, add a `.integration_locked` file to the root of the example.* + +**Warning:** all of the `core` examples use `ErrorViews::unlocalized_default()` for their error views, except for the example that specifically regards error views. This is done solely for convenience and to reduce the burden of maintaining all the examples. In real apps, error views will be provided for you in development for convenience, but you'll have to provide your own in production (unless you explicitly force the development error views to come with you to production, with `::unlocalized_default()`, which is **not** recommended). + +Note that the examples all spell out the lifetimes of view functions that take state in full, so, when you see function signatures like `fn my_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a MyStateRx) -> View`, don't despair! This is the full form of these, written out so you can see how everything works, however there is a convenience macro that lets you elide these lifetimes: `#[auto_scope]` (included in `perseus::prelude`). Examples of autoscoped view functions can be found in the `core/basic` example and the `core/capsules` example (on a capsule). You're welcome to use this macro, or not, whichever you prefer. diff --git a/examples/comprehensive/tiny/src/main.rs b/examples/comprehensive/tiny/src/main.rs index 7035abcbf8..db9081924c 100644 --- a/examples/comprehensive/tiny/src/main.rs +++ b/examples/comprehensive/tiny/src/main.rs @@ -4,16 +4,17 @@ use sycamore::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main() -> PerseusApp { PerseusApp::new() - .template(Template::new("index").template(|cx| { - view! { cx, - p { "Hello World!" } - } - })) - .error_pages(ErrorPages::new( - |cx, url, status, err, _| view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - }, - |cx, _, _, _, _| view! { cx, - title { "Error" } - })) + .template( + Template::build("index") + .view(|cx| { + view! { cx, + p { "Hello World!" } + } + }) + .build(), + ) + // This forces Perseus to use the development defaults in production, which just + // lets you easily deploy this app. In a real app, you should always provide your own + // error pages! + .error_views(ErrorViews::unlocalized_development_default()) } diff --git a/examples/core/basic/src/error_pages.rs b/examples/core/basic/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/basic/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages() -> ErrorPages { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/basic/src/main.rs b/examples/core/basic/src/main.rs index 02fdf9edfb..90aa76de38 100644 --- a/examples/core/basic/src/main.rs +++ b/examples/core/basic/src/main.rs @@ -1,12 +1,12 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main() -> PerseusApp { PerseusApp::new() .template(crate::templates::index::get_template()) .template(crate::templates::about::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + // This is for example usage only, you should specify your own error pages + .error_views(ErrorViews::unlocalized_development_default()) } diff --git a/examples/core/basic/src/templates/about.rs b/examples/core/basic/src/templates/about.rs index ff2763d2db..12989c60c7 100644 --- a/examples/core/basic/src/templates/about.rs +++ b/examples/core/basic/src/templates/about.rs @@ -15,5 +15,5 @@ fn head(cx: Scope) -> View { } pub fn get_template() -> Template { - Template::new("about").template(about_page).head(head) + Template::build("about").view(about_page).head(head).build() } diff --git a/examples/core/basic/src/templates/index.rs b/examples/core/basic/src/templates/index.rs index f93d0c5b82..160a1a9e92 100644 --- a/examples/core/basic/src/templates/index.rs +++ b/examples/core/basic/src/templates/index.rs @@ -2,14 +2,14 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, ReactiveState, Clone)] #[rx(alias = "IndexPageStateRx")] struct IndexPageState { pub greeting: String, } -#[perseus::template] -fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPageStateRx<'a>) -> View { +#[auto_scope] +fn index_page(cx: Scope, state: &IndexPageStateRx) -> View { view! { cx, p { (state.greeting.get()) } a(href = "about", id = "about-link") { "About!" } @@ -17,10 +17,11 @@ fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPageStateRx<'a>) -> View() -> Template { - Template::new("index") + Template::build("index") .build_state_fn(get_build_state) - .template_with_state(index_page) + .view_with_state(index_page) .head_with_state(head) + .build() } #[engine_only_fn] @@ -31,8 +32,8 @@ fn head(cx: Scope, _props: IndexPageState) -> View { } #[engine_only_fn] -async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCause { - Ok(IndexPageState { +async fn get_build_state(_info: StateGeneratorInfo<()>) -> IndexPageState { + IndexPageState { greeting: "Hello World!".to_string(), - }) + } } diff --git a/examples/core/basic/tests/main.rs b/examples/core/basic/tests/main.rs index 4b1547313a..b44f4c0d32 100644 --- a/examples/core/basic/tests/main.rs +++ b/examples/core/basic/tests/main.rs @@ -9,7 +9,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { assert!(url.as_ref().starts_with("http://localhost:8080")); // The greeting was passed through using build state - wait_for_checkpoint!("initial_state_present", 0, c); wait_for_checkpoint!("page_interactive", 0, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Hello World!"); @@ -22,7 +21,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { c.find(Locator::Id("about-link")).await?.click().await?; let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080/about")); - wait_for_checkpoint!("initial_state_not_present", 0, c); wait_for_checkpoint!("page_interactive", 1, c); // Make sure the hardcoded text there exists let text = c.find(Locator::Css("p")).await?.text().await?; @@ -31,7 +29,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { assert!(title.contains("About Page")); // Make sure we get initial state if we refresh c.refresh().await?; - wait_for_checkpoint!("initial_state_present", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); Ok(()) } diff --git a/examples/core/capsules/.gitignore b/examples/core/capsules/.gitignore new file mode 100644 index 0000000000..6df95ee360 --- /dev/null +++ b/examples/core/capsules/.gitignore @@ -0,0 +1,3 @@ +dist/ +target_engine/ +target_wasm/ diff --git a/examples/core/capsules/Cargo.toml b/examples/core/capsules/Cargo.toml new file mode 100644 index 0000000000..1a0ca1c43c --- /dev/null +++ b/examples/core/capsules/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "perseus-example-capsules" +version = "0.4.0-beta.11" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +perseus = { path = "../../../packages/perseus", features = [ "hydrate" ] } +sycamore = "^0.8.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +lazy_static = "1" + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +fantoccini = "0.17" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1", features = [ "macros", "rt", "rt-multi-thread" ] } +## **WARNING!** Before running this example outside the Perseus repo, replace the below line with +## the one commented out below it (changing the path dependency to the version you want to use) +perseus-warp = { package = "perseus-integration", path = "../../../packages/perseus-integration", default-features = false } +# perseus-warp = { path = "../../../packages/perseus-warp", features = [ "dflt-server" ] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/examples/core/capsules/README.md b/examples/core/capsules/README.md new file mode 100644 index 0000000000..70788b41d3 --- /dev/null +++ b/examples/core/capsules/README.md @@ -0,0 +1,3 @@ +# Capsules Example + +This example showcases the capsules system of Perseus, which allows you to create a component that can hook into Perseus' state platform to build its own state, including multiple versions of itself (as templates generate pages, capsules generate widgets), which can be included on pages. diff --git a/examples/core/capsules/src/capsules/greeting.rs b/examples/core/capsules/src/capsules/greeting.rs new file mode 100644 index 0000000000..c4d7ef8a55 --- /dev/null +++ b/examples/core/capsules/src/capsules/greeting.rs @@ -0,0 +1,51 @@ +use lazy_static::lazy_static; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; + +// Putting our capsule in a static means it can easily be included in templates! +lazy_static! { + // This `PerseusNodeType` alias will resolve to `SsrNode`/`DomNode`/`HydrateNode` automatically + // as needed. This is needed because `lazy_static!` doesn't support generics, like `G: Html`. + // Perseus can bridge the gap internally with type coercions, so this "just works"! + pub static ref GREETING: Capsule = get_capsule(); +} + +#[auto_scope] +fn greeting_capsule(cx: Scope, state: &GreetingStateRx, props: GreetingProps) -> View { + view! { cx, + p(style = format!("color: {};", props.color)) { (state.greeting.get()) } + } +} + +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "GreetingStateRx")] +struct GreetingState { + greeting: String, +} + +// This needs to be public, because it will be passed to us by templates +#[derive(Clone)] +pub struct GreetingProps { + pub color: String, +} + +pub fn get_capsule() -> Capsule { + // Template properties, to do with state generation, are set on a template + // that's passed to the capsule. Note that we don't call `.build()` on the + // template, because we want a capsule, not a template (we're using the + // `TemplateInner`). + Capsule::build(Template::build("greeting").build_state_fn(get_build_state)) + .empty_fallback() + // Very importantly, we declare our views on the capsule, **not** the template! + // This lets us use properties. + .view_with_state(greeting_capsule) + .build() +} + +#[engine_only_fn] +async fn get_build_state(_info: StateGeneratorInfo<()>) -> GreetingState { + GreetingState { + greeting: "Hello world! (I'm a widget!)".to_string(), + } +} diff --git a/examples/core/capsules/src/capsules/ip.rs b/examples/core/capsules/src/capsules/ip.rs new file mode 100644 index 0000000000..e890e4ed96 --- /dev/null +++ b/examples/core/capsules/src/capsules/ip.rs @@ -0,0 +1,45 @@ +use lazy_static::lazy_static; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; + +lazy_static! { + pub static ref IP: Capsule = get_capsule(); +} + +// Note the use of props as `()`, indicating that this capsule doesn't take any +// properties +fn ip_capsule(cx: Scope, state: IpState, _props: ()) -> View { + view! { cx, + p { (state.ip) } + } +} + +// This uses unreactive state, just to show that it works +#[derive(Serialize, Deserialize, Clone, UnreactiveState)] +struct IpState { + ip: String, +} + +pub fn get_capsule() -> Capsule { + Capsule::build(Template::build("ip").request_state_fn(get_request_state)) + .empty_fallback() + // Very importantly, we declare our views on the capsule, **not** the template! + // This lets us use properties. + .view_with_unreactive_state(ip_capsule) + .build() +} + +#[engine_only_fn] +async fn get_request_state(_info: StateGeneratorInfo<()>, req: Request) -> IpState { + IpState { + ip: format!( + "{:?}", + req.headers() + // NOTE: This header can be trivially spoofed, and may well not be the user's actual + // IP address + .get("X-Forwarded-For") + .unwrap_or(&perseus::http::HeaderValue::from_str("hidden from view!").unwrap()) + ), + } +} diff --git a/examples/core/capsules/src/capsules/links.rs b/examples/core/capsules/src/capsules/links.rs new file mode 100644 index 0000000000..f2a3fa9c5c --- /dev/null +++ b/examples/core/capsules/src/capsules/links.rs @@ -0,0 +1,34 @@ +use lazy_static::lazy_static; +use perseus::prelude::*; +use sycamore::prelude::*; + +// There are a fair few pages in this example, so this serves as a little +// navigation bar. (You could easily do this with a normal Sycamore component, +// and that would probably make more sense, but this is a capsules example!) + +lazy_static! { + pub static ref LINKS: Capsule = get_capsule(); +} + +fn links_capsule(cx: Scope, _: ()) -> View { + view! { cx, + div(style = "margin-top: 1rem;") { + a(href = "") { "Index" } + br {} + a(href = "about") { "About" } + br {} + a(href = "clock") { "Clock" } + br {} + a(href = "four") { "4" } + br {} + a(href = "calc") { "Calc" } + } + } +} + +pub fn get_capsule() -> Capsule { + Capsule::build(Template::build("links")) + .empty_fallback() + .view(links_capsule) + .build() +} diff --git a/examples/core/capsules/src/capsules/mod.rs b/examples/core/capsules/src/capsules/mod.rs new file mode 100644 index 0000000000..6b9f021aef --- /dev/null +++ b/examples/core/capsules/src/capsules/mod.rs @@ -0,0 +1,6 @@ +pub mod greeting; +pub mod ip; +pub mod links; +pub mod number; +pub mod time; +pub mod wrapper; diff --git a/examples/core/capsules/src/capsules/number.rs b/examples/core/capsules/src/capsules/number.rs new file mode 100644 index 0000000000..092c452924 --- /dev/null +++ b/examples/core/capsules/src/capsules/number.rs @@ -0,0 +1,82 @@ +use std::num::ParseIntError; + +use lazy_static::lazy_static; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; + +// This is a fairly pointless capsule that uses incremental generation to +// generate widgets for numbers, which it then displays. This shows how errors +// work in capsules (by passing through a non-number). + +lazy_static! { + pub static ref NUMBER: Capsule = get_capsule(); +} + +// Note the use of props as `()`, indicating that this capsule doesn't take any +// properties +fn time_capsule(cx: Scope, state: Number, _props: ()) -> View { + view! { cx, + span { (state.number) } + // This is an example to demonstrate self-recursion, as well as taking + // a particular incremental path that has incremental dependencies + // itself. Perseus resolves this without problems. + (if state.number == 5 { + view! { cx, (NUMBER.widget(cx, "/6", ())) } + } else { + View::empty() + }) + } +} + +#[derive(Serialize, Deserialize, Clone, UnreactiveState)] +struct Number { + number: u16, +} + +pub fn get_capsule() -> Capsule { + Capsule::build( + Template::build("number") + .build_paths_fn(get_build_paths) + .build_state_fn(get_build_state) + .incremental_generation(), + ) + .empty_fallback() + .view_with_unreactive_state(time_capsule) + .build() +} + +#[engine_only_fn] +async fn get_build_state( + info: StateGeneratorInfo<()>, +) -> Result> { + // The path should be a simple number + let number = if info.path.contains('/') { + // Easter egg! We'll add multiple numbers together + let mut final_num = 0; + for num in info.path.split('/') { + let parsed = num.parse::().map_err(|e| BlamedError { + error: e, + blame: ErrorBlame::Client(None), + })?; + final_num += parsed; + } + final_num + } else { + // If the number is invalid, that's the client's fault + info.path.parse::().map_err(|e| BlamedError { + error: e, + blame: ErrorBlame::Client(None), + })? + }; + + Ok(Number { number }) +} + +#[engine_only_fn] +async fn get_build_paths() -> BuildPaths { + BuildPaths { + paths: vec!["4".to_string()], + extra: ().into(), + } +} diff --git a/examples/core/capsules/src/capsules/time.rs b/examples/core/capsules/src/capsules/time.rs new file mode 100644 index 0000000000..734314d295 --- /dev/null +++ b/examples/core/capsules/src/capsules/time.rs @@ -0,0 +1,60 @@ +use lazy_static::lazy_static; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; + +lazy_static! { + pub static ref TIME: Capsule = get_capsule(); +} + +// Note the use of props as `()`, indicating that this capsule doesn't take any +// properties +#[auto_scope] +fn time_capsule(cx: Scope, state: &TimeStateRx, _props: ()) -> View { + view! { cx, + // We'll put this inside a `p`, so we'll use a `span` + span { (state.time.get()) } + } +} + +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "TimeStateRx")] +struct TimeState { + time: String, +} + +pub fn get_capsule() -> Capsule { + Capsule::build( + Template::build("time") + .build_state_fn(get_build_state) + // This setup means, ever five seconds, `should_revalidate` will be executed to check if + // the capsule should really revalidate. If it should (which, since that function always + // returns `true`, it always should), `get_build_state` will be re-executed. + .should_revalidate_fn(should_revalidate) + .revalidate_after("5s"), + ) + .empty_fallback() + // Very importantly, we declare our views on the capsule, **not** the template! + // This lets us use properties. + .view_with_state(time_capsule) + .build() +} + +// This will get the system time when the app was built +#[engine_only_fn] +async fn get_build_state(_info: StateGeneratorInfo<()>) -> TimeState { + TimeState { + time: format!("{:?}", std::time::SystemTime::now()), + } +} + +#[engine_only_fn] +async fn should_revalidate( + // This takes the same arguments as request state + _info: StateGeneratorInfo<()>, + _req: perseus::Request, +) -> bool { + // For simplicity's sake, this will always say we should revalidate, but you + // could make this check any condition + true +} diff --git a/examples/core/capsules/src/capsules/wrapper.rs b/examples/core/capsules/src/capsules/wrapper.rs new file mode 100644 index 0000000000..59c52d0a2d --- /dev/null +++ b/examples/core/capsules/src/capsules/wrapper.rs @@ -0,0 +1,24 @@ +use lazy_static::lazy_static; +use perseus::prelude::*; +use sycamore::prelude::*; + +use super::greeting::{GreetingProps, GREETING}; + +lazy_static! { + pub static ref WRAPPER: Capsule = get_capsule(); +} + +// A simple wrapper capsule to show how capsules can use capsules +fn wrapper_capsule(cx: Scope, props: GreetingProps) -> View { + view! { cx, + // Because `props` is an owned variable, it has to be cloned here + (GREETING.widget(cx, "", props.clone())) + } +} + +pub fn get_capsule() -> Capsule { + Capsule::build(Template::build("wrapper")) + .empty_fallback() + .view(wrapper_capsule) + .build() +} diff --git a/examples/core/capsules/src/main.rs b/examples/core/capsules/src/main.rs new file mode 100644 index 0000000000..debb19198e --- /dev/null +++ b/examples/core/capsules/src/main.rs @@ -0,0 +1,27 @@ +mod capsules; +mod templates; + +use perseus::prelude::*; + +#[perseus::main(perseus_warp::dflt_server)] +pub fn main() -> PerseusApp { + PerseusApp::new() + .template(crate::templates::index::get_template()) + .template(crate::templates::about::get_template()) + .template(crate::templates::clock::get_template()) + .template(crate::templates::calc::get_template()) + .template(crate::templates::four::get_template()) + // We use the reference pattern here, storing the capsule in a static. However, we had + // to specify a concrete type for `G`, the rendering backend. Since we used + // `PerseusNodeType`, which will always intelligently line up with the `G` here, we + // know they'll match, but the compiler doesn't. Unlike `.capsule()`, + // `.capsule_ref()` can perform internal type coercions to bridge this + // gap. (It learn more about the reference pattern vs. the function one, see the book.) + .capsule_ref(&*crate::capsules::greeting::GREETING) + .capsule_ref(&*crate::capsules::wrapper::WRAPPER) + .capsule_ref(&*crate::capsules::ip::IP) + .capsule_ref(&*crate::capsules::time::TIME) + .capsule_ref(&*crate::capsules::number::NUMBER) + .capsule_ref(&*crate::capsules::links::LINKS) + .error_views(ErrorViews::unlocalized_development_default()) +} diff --git a/examples/core/capsules/src/templates/about.rs b/examples/core/capsules/src/templates/about.rs new file mode 100644 index 0000000000..0b2ddb7e9f --- /dev/null +++ b/examples/core/capsules/src/templates/about.rs @@ -0,0 +1,27 @@ +use crate::capsules::ip::IP; +use crate::capsules::links::LINKS; +use perseus::prelude::*; +use sycamore::prelude::*; + +fn about_page(cx: Scope) -> View { + view! { cx, + // This will display the user's IP address + (IP.widget(cx, "", ())) + (LINKS.widget(cx, "", ())) + } +} + +pub fn get_template() -> Template { + Template::build("about") + .view(about_page) + // This is extremely important. Notice that this template doesn't have any state of its own? + // Well, that means it should be able to be built at build-time! However, the `ip` + // capsule uses request state, which means anything that uses it also has to be built at + // request-time. That means Perseus needs to 'reschedule' the build of this page from + // build-time to request-time. This can incur a performance penalty for users of your site + // (as they'll have to wait for the server to generate the `ip` capsule's state, rather then + // just sending them some pre-generated HTML), so Perseus makes sure it has your permission + // first. Try commenting out this line, the app will fail to build. + .allow_rescheduling() + .build() +} diff --git a/examples/core/capsules/src/templates/calc.rs b/examples/core/capsules/src/templates/calc.rs new file mode 100644 index 0000000000..37aac23ee1 --- /dev/null +++ b/examples/core/capsules/src/templates/calc.rs @@ -0,0 +1,80 @@ +use crate::capsules::links::LINKS; +use crate::capsules::number::NUMBER; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; + +#[auto_scope] +fn calc_page(cx: Scope, state: &CalcStateRx) -> View { + view! { cx, + // This was *not* built at build-time in `number`, so we're incrementally + // generating it. Importantly, Perseus can figure out that this should just + // be added to the build paths list of the `number` widget, so we don't need + // to reschedule the building of this widget + p { + "The number fifty-six: " + // See `number.rs` for why this yields `56` + (NUMBER.widget(cx, "/5", ())) + "." + } + // Now, let me be clear. Using a widget as an addition function is a woeful abuse + // of Perseus, but it does serve as an excellent example for how powerful widgets really + // are. This is using incremental generation, and the user is simply providing the last + // number to add to the present sum (42, of course). Every change to the input will trigger + // a request to the server, which will generate the appropriate widget (aka. sum) and cache + // it (meaning future requests will just return that). Note that the browser also performs some + // caching, so, if you try typing, say, `3`, and then `33`, and then backspacing the last `3` + // so you're back to just `3`, you'll notice in the DevTools that there are no new requests, + // since you've already typed in `3` before. + // + // This works because *everything* is reactive, literally everything. + p { + "The sum of the state numbers: " + (NUMBER.widget( + cx, + // We're using this widget as a glorified addition function + &format!( + "/{}/{}", + // We need to make them strings first + state + .numbers + .get() + .iter() + .map(|n| n.to_string()) + .collect::>() + .join("/"), + state.user_number.get() + ), + () + )) + "." + } + p { "Type your number below..." } + input(bind:value = state.user_number) {} + (LINKS.widget(cx, "", ())) + } +} + +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "CalcStateRx")] +struct CalcState { + numbers: Vec, + // This has to be a string to work with `bind:value` + user_number: String, +} + +pub fn get_template() -> Template { + Template::build("calc") + .view_with_state(calc_page) + .build_state_fn(get_build_state) + .build() +} + +#[engine_only_fn] +async fn get_build_state(_info: StateGeneratorInfo<()>) -> CalcState { + CalcState { + numbers: vec![5, 10, 27], + // This can be modified by the user + user_number: "0".to_string(), + } +} diff --git a/examples/core/capsules/src/templates/clock.rs b/examples/core/capsules/src/templates/clock.rs new file mode 100644 index 0000000000..a6e1dea410 --- /dev/null +++ b/examples/core/capsules/src/templates/clock.rs @@ -0,0 +1,27 @@ +use crate::capsules::links::LINKS; +use crate::capsules::time::TIME; +use perseus::prelude::*; +use sycamore::prelude::*; + +fn clock_page(cx: Scope) -> View { + // Nothing's wrong with preparing a widget in advance, especially if you want to + // use the same one in a few places (this will avoid unnecessary fetches in + // some cases, see the book for details) + let time = TIME.widget(cx, "", ()); + + view! { cx, + p { + "The most recent update to the time puts it at " + (time) + } + (LINKS.widget(cx, "", ())) + } +} + +pub fn get_template() -> Template { + Template::build("clock") + .view(clock_page) + // See `about.rs` for an explanation of this + .allow_rescheduling() + .build() +} diff --git a/examples/core/capsules/src/templates/four.rs b/examples/core/capsules/src/templates/four.rs new file mode 100644 index 0000000000..1359ea86f2 --- /dev/null +++ b/examples/core/capsules/src/templates/four.rs @@ -0,0 +1,23 @@ +use crate::capsules::links::LINKS; +use crate::capsules::number::NUMBER; +use perseus::prelude::*; +use sycamore::prelude::*; + +fn four_page(cx: Scope) -> View { + view! { cx, + p { + "The number four: " + // We're using the second argument to provide a *widget path* within the capsule + (NUMBER.widget(cx, "/4", ())) + "." + } + (LINKS.widget(cx, "", ())) + } +} + +pub fn get_template() -> Template { + // Notice that this doesn't need to have rescheduling, because the widget it + // uses was built at build-time as part of `number`'s `get_build_paths` + // function. + Template::build("four").view(four_page).build() +} diff --git a/examples/core/capsules/src/templates/index.rs b/examples/core/capsules/src/templates/index.rs new file mode 100644 index 0000000000..ff1deadc0d --- /dev/null +++ b/examples/core/capsules/src/templates/index.rs @@ -0,0 +1,31 @@ +use crate::capsules::greeting::GreetingProps; +use crate::capsules::links::LINKS; +use crate::capsules::wrapper::WRAPPER; +use perseus::prelude::*; +use sycamore::prelude::*; + +fn index_page(cx: Scope) -> View { + view! { cx, + p { "Hello World!" } + a(href = "about") { "About" } + // This capsule wraps another capsule + (WRAPPER.widget(cx, "", GreetingProps { color: "red".to_string() })) + + // This is not the prettiest function call, deliberately, to encourage you + // to make this sort of thing part of the template it's used in, or to use + // a Sycamore component instead (which, for a navbar, we should, this is + // just an example) + (LINKS.widget(cx, "", ())) + } +} + +#[engine_only_fn] +fn head(cx: Scope) -> View { + view! { cx, + title { "Index Page" } + } +} + +pub fn get_template() -> Template { + Template::build("index").view(index_page).head(head).build() +} diff --git a/examples/core/capsules/src/templates/mod.rs b/examples/core/capsules/src/templates/mod.rs new file mode 100644 index 0000000000..d18debe45d --- /dev/null +++ b/examples/core/capsules/src/templates/mod.rs @@ -0,0 +1,5 @@ +pub mod about; +pub mod calc; +pub mod clock; +pub mod four; +pub mod index; diff --git a/examples/core/custom_server/src/error_pages.rs b/examples/core/custom_server/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/custom_server/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages() -> ErrorPages { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/custom_server/src/main.rs b/examples/core/custom_server/src/main.rs index 9f3143d522..873e0024cb 100644 --- a/examples/core/custom_server/src/main.rs +++ b/examples/core/custom_server/src/main.rs @@ -1,7 +1,6 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; // Note: we use fully-qualified paths in the types to this function so we don't // have to target-gate some more imports @@ -11,7 +10,8 @@ pub async fn dflt_server< M: perseus::stores::MutableStore + 'static, T: perseus::i18n::TranslationsManager + 'static, >( - props: perseus::server::ServerProps, + turbine: &'static perseus::turbine::Turbine, + opts: perseus::server::ServerOptions, (host, port): (String, u16), ) { use perseus_warp::perseus_routes; @@ -28,7 +28,7 @@ pub async fn dflt_server< // those universal properties Usually, you shouldn't ever have to worry // about the value of the properties, which are set from your `PerseusApp` // config - let perseus_routes = perseus_routes(props).await; + let perseus_routes = perseus_routes(turbine, opts).await; // And now set up our own routes // You could set up as many of these as you like in a production app // Note that they mustn't define anything under `/.perseus` or anything @@ -57,5 +57,5 @@ pub fn main() -> PerseusApp { PerseusApp::new() .template(crate::templates::index::get_template()) .template(crate::templates::about::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) } diff --git a/examples/core/custom_server/src/templates/about.rs b/examples/core/custom_server/src/templates/about.rs index 70bb55b3b5..cbebcd1bb9 100644 --- a/examples/core/custom_server/src/templates/about.rs +++ b/examples/core/custom_server/src/templates/about.rs @@ -1,5 +1,5 @@ -use perseus::Template; -use sycamore::prelude::{view, Html, Scope, View}; +use perseus::prelude::*; +use sycamore::prelude::*; fn about_page(cx: Scope) -> View { view! { cx, @@ -8,5 +8,5 @@ fn about_page(cx: Scope) -> View { } pub fn get_template() -> Template { - Template::new("about").template(about_page) + Template::build("about").view(about_page).build() } diff --git a/examples/core/custom_server/src/templates/index.rs b/examples/core/custom_server/src/templates/index.rs index 64e71514e4..664d2c48bc 100644 --- a/examples/core/custom_server/src/templates/index.rs +++ b/examples/core/custom_server/src/templates/index.rs @@ -1,5 +1,5 @@ -use perseus::{Html, Template}; -use sycamore::prelude::{view, Scope, View}; +use perseus::prelude::*; +use sycamore::prelude::*; fn index_page(cx: Scope) -> View { view! { cx, @@ -9,5 +9,5 @@ fn index_page(cx: Scope) -> View { } pub fn get_template() -> Template { - Template::new("index").template(index_page) + Template::build("index").view(index_page).build() } diff --git a/examples/core/custom_server/tests/main.rs b/examples/core/custom_server/tests/main.rs index 1be50a2558..8cb5afada2 100644 --- a/examples/core/custom_server/tests/main.rs +++ b/examples/core/custom_server/tests/main.rs @@ -8,7 +8,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); - wait_for_checkpoint!("initial_state_present", 0, c); wait_for_checkpoint!("page_interactive", 0, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Hello World!"); @@ -17,14 +16,12 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { c.find(Locator::Id("about-link")).await?.click().await?; let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080/about")); - wait_for_checkpoint!("initial_state_not_present", 0, c); wait_for_checkpoint!("page_interactive", 1, c); // Make sure the hardcoded text there exists let text = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(text, "About."); // Make sure we get initial state if we refresh c.refresh().await?; - wait_for_checkpoint!("initial_state_present", 0, c); // Check the API routes (we test with a server) // The echo route should echo back everything we give it diff --git a/examples/core/error_views/.gitignore b/examples/core/error_views/.gitignore new file mode 100644 index 0000000000..6df95ee360 --- /dev/null +++ b/examples/core/error_views/.gitignore @@ -0,0 +1,3 @@ +dist/ +target_engine/ +target_wasm/ diff --git a/examples/core/error_views/Cargo.toml b/examples/core/error_views/Cargo.toml new file mode 100644 index 0000000000..d69e19973e --- /dev/null +++ b/examples/core/error_views/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "perseus-example-base" +version = "0.4.0-beta.11" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +perseus = { path = "../../../packages/perseus", features = [ "hydrate" ] } +sycamore = "^0.8.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +fantoccini = "0.17" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1", features = [ "macros", "rt", "rt-multi-thread" ] } +## **WARNING!** Before running this example outside the Perseus repo, replace the below line with +## the one commented out below it (changing the path dependency to the version you want to use) +perseus-warp = { package = "perseus-integration", path = "../../../packages/perseus-integration", default-features = false } +# perseus-warp = { path = "../../../packages/perseus-warp", features = [ "dflt-server" ] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/examples/core/error_views/README.md b/examples/core/error_views/README.md new file mode 100644 index 0000000000..8a3bb8ee4c --- /dev/null +++ b/examples/core/error_views/README.md @@ -0,0 +1,5 @@ +# Error Views Example + +This example shows how error views work in Perseus. Although the other examples forcefully set the inbuilt development default error views for simplicity, you should always create your own error views in real-world apps, especially if you're using internationalization: this example shows you how! + +Note that the index page of this example is deliberately designed to panic immediately, to showcase the panic handler. diff --git a/examples/core/error_views/src/error_views.rs b/examples/core/error_views/src/error_views.rs new file mode 100644 index 0000000000..2d5c3fb9c9 --- /dev/null +++ b/examples/core/error_views/src/error_views.rs @@ -0,0 +1,129 @@ +use perseus::errors::ClientError; +use perseus::prelude::*; +use sycamore::prelude::*; + +// Like templates, error views are generic over `G`, so that they can be +// rendered ahead of time on the engine-side when an error occurs +pub fn get_error_views() -> ErrorViews { + // Creating a set of error views is a matter of creating a single handler + // function that can respond to any error. This handler takes a Sycamore scope, + // the actual error (`perseus::errors::ClientError`), some information about + // the context of the error (e.g. do we have access to a translator?), and the + // position of the error. + // + // The `ErrorContext` is the most important thing to take account of, after the + // error itself, as it will twll you what you have access to. For example, if + // an error occurs while trying to set up translations, you won't have access to + // a translator, which can be a problem if you're using i18n. Alternately, in a + // critical error, you might not even have access to a `Reactor`, meaning no + // global state, no preloading, etc. Importantly, if an error view is rendered + // on the engine-side, it will most likely not have access to a global state, + // even if it does have a `Reactor`, due to internal constraints. + // + // The error position is one of `Page`, `Widget`, or `Popup`, which may impact + // your styling choices. For example, it would be a little pointless to style + // your error view for a full-page layout if it's going to be rendered in a + // tiny little popup. Errors that occur in widgets will replace the widget. + // Popup errors are usually used for internal problems, such as corrupted state, + // rather than an error that the user is more likely to be able to do something + // about, like a 404. On an initial load, when the user first comes to a page on + // your site, only HTTP errors (e.g. 404 not found, 500 internal server error) + // will take up the whole page: anything else, like an internal error, will + // be rendered as a popup, as the actual content of the page will already + // have been prerendered, and it would be pointless and frustrating for the + // user to have it replaced with an error message. On subsequent loads, the + // same rule is upheld by default, but you can control this becavior by + // setting the `subsequent_load_determinant` on this `ErrorViews` `struct` + // (see the API docs for further details). + // + // This handler is expected to return a tuple of two views: the first one for + // the `` (usually containing a title only, as it's not a good idea to + // load extra material like new stylesheets on an error, as it might be a + // network error), and the second one for the body (to be displayed in + // `err_pos`). + ErrorViews::new(|cx, err, _err_info, _err_pos| { + match err { + // Errors from the server, like 404s; these are best displayed over the whole + // page + ClientError::ServerError { + status, + // This is fully formatted with newlines and tabs for the error and its causes + message: _ + } => match status { + // This one is usually handled separately + 404 => ( + view! { cx, + title { "Page not found" } + }, + view! { cx, + p { "Sorry, that page doesn't seem to exist." } + } + ), + // If the status is 4xx, it's a client-side problem (which is weird, and might indicate tampering) + _ if (400..500).contains(&status) => ( + view! { cx, + title { "Error" } + }, + view! { cx, + p { "There was something wrong with the last request, please try reloading the page." } + } + ), + // 5xx is a server error + _ => ( + view! { cx, + title { "Error" } + }, + view! { cx, + p { "Sorry, our server experienced an internal error. Please try reloading the page." } + } + ) + }, + // A panic (yes, you can handle them here!). After this error is displayed, the entire + // app will terminate, so buttons or other reactive elements are pointless. + // + // The argument here is the formatted panic message. + ClientError::Panic(_) => ( + view! { cx, + title { "Critical error" } + }, + view! { cx, + p { "Sorry, but a critical internal error has occurred. This has been automatically reported to our team, who'll get on it as soon as possible. In the mean time, please try reloading the page." } + } + ), + // Network errors (but these could be caused by unexpected server rejections) + ClientError::FetchError(_) => ( + view! { cx, + title { "Error" } + }, + view! { cx, + p { "A network error occurred, do you have an internet connection? (If you do, try reloading the page.)" } + } + ), + + // Usually, everything below here will just be handled with a wildcard + // for simplicity. + + // An internal failure within Perseus (these can very rarely happen due + // to network errors or corruptions) + ClientError::InvariantError(_) | + // Only if you're using plugins + ClientError::PluginError(_) | + // Only if you're using state freezing + ClientError::ThawError(_) | + // Severe failures in working with the browser (this doesn't do a lot + // right now, but it will in future, as Perseus supports PWAs etc.) + ClientError::PlatformError(_) | + // Only if you're using preloads (these are usually better + // caught at the time of the function's execution, but sometimes + // you'll just want to leave them to a popup error) + ClientError::PreloadError(_) => ( + view! { cx, + title { "Error" } + }, + view! { cx, + p { (format!("An internal error has occurred: '{}'.", err)) } + } + ) + } + }) +} diff --git a/examples/core/error_views/src/main.rs b/examples/core/error_views/src/main.rs new file mode 100644 index 0000000000..2ab993924c --- /dev/null +++ b/examples/core/error_views/src/main.rs @@ -0,0 +1,24 @@ +mod error_views; +mod templates; + +use perseus::prelude::*; + +#[perseus::main(perseus_warp::dflt_server)] +pub fn main() -> PerseusApp { + PerseusApp::new() + .template(crate::templates::index::get_template()) + // The same convention of a function to return the needed `struct` is + // used for both templates and error views + .error_views(crate::error_views::get_error_views()) + // This lets you specify a siimpel function to be executed when a panic + // occurs. This will happen *before* the error is formatted and sent to + // your usual error views system. This is best used for crash analytics, + // although you must be careful to make sure this function cannot panic + // itself, or no error message will be sent to the user, and the app will + // appear to freeze entirely. + // + // Note that Perseus automatically writes an explanatory message to the console + // before any further panic action is taken, just in case the visual message + // doesn't work out for whatever reason, so there's no need to do that here. + .panic_handler(|_panic_info| perseus::web_log!("The app has panicked.")) +} diff --git a/examples/core/error_views/src/templates/index.rs b/examples/core/error_views/src/templates/index.rs new file mode 100644 index 0000000000..10c9bd6076 --- /dev/null +++ b/examples/core/error_views/src/templates/index.rs @@ -0,0 +1,26 @@ +use perseus::prelude::*; +use sycamore::prelude::*; + +fn index_page(cx: Scope) -> View { + // Deliberate panic to show how panic handling works (in an `on_mount` so we + // still reach the right checkpoints for testing) + #[cfg(target_arch = "wasm32")] + on_mount(cx, || { + panic!(); + }); + + view! { cx, + p { "Hello World!" } + } +} + +#[engine_only_fn] +fn head(cx: Scope) -> View { + view! { cx, + title { "Index Page" } + } +} + +pub fn get_template() -> Template { + Template::build("index").view(index_page).head(head).build() +} diff --git a/examples/core/error_views/src/templates/mod.rs b/examples/core/error_views/src/templates/mod.rs new file mode 100644 index 0000000000..33edc959c9 --- /dev/null +++ b/examples/core/error_views/src/templates/mod.rs @@ -0,0 +1 @@ +pub mod index; diff --git a/examples/core/error_views/tests/main.rs b/examples/core/error_views/tests/main.rs new file mode 100644 index 0000000000..9120119d8c --- /dev/null +++ b/examples/core/error_views/tests/main.rs @@ -0,0 +1,31 @@ +use fantoccini::{Client, Locator}; +use perseus::wait_for_checkpoint; + +#[perseus::test] +async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { + c.goto("http://localhost:8080").await?; + wait_for_checkpoint!("begin", 0, c); + let url = c.current_url().await?; + assert!(url.as_ref().starts_with("http://localhost:8080")); + + wait_for_checkpoint!("page_interactive", 0, c); + let panic_msg = c + .find(Locator::Css("#__perseus_popup_error > p")) + .await? + .text() + .await?; + assert!(panic_msg.contains("critical internal error")); + + // Try out a 404 + c.goto("http://localhost:8080/foo").await?; + wait_for_checkpoint!("not_found", 0, c); + let err_msg = c.find(Locator::Css("#root > p")).await?.text().await?; + assert_eq!(err_msg, "Sorry, that page doesn't seem to exist."); + + // For some reason, retrieving the inner HTML or text of a `` doesn't + // work + let title = c.find(Locator::Css("title")).await?.html(false).await?; + assert!(title.contains("Page not found")); + + Ok(()) +} diff --git a/examples/core/freezing_and_thawing/src/error_pages.rs b/examples/core/freezing_and_thawing/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/freezing_and_thawing/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/freezing_and_thawing/src/global_state.rs b/examples/core/freezing_and_thawing/src/global_state.rs index 7cbf31bfca..9be39d2886 100644 --- a/examples/core/freezing_and_thawing/src/global_state.rs +++ b/examples/core/freezing_and_thawing/src/global_state.rs @@ -12,8 +12,8 @@ pub struct AppState { } #[engine_only_fn] -async fn get_build_state(_locale: String) -> RenderFnResult<AppState> { - Ok(AppState { +async fn get_build_state(_locale: String) -> AppState { + AppState { test: "Hello World!".to_string(), - }) + } } diff --git a/examples/core/freezing_and_thawing/src/main.rs b/examples/core/freezing_and_thawing/src/main.rs index f4b5c158f5..b710ecc221 100644 --- a/examples/core/freezing_and_thawing/src/main.rs +++ b/examples/core/freezing_and_thawing/src/main.rs @@ -1,14 +1,13 @@ -mod error_pages; mod global_state; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) .template(crate::templates::about::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) .global_state_creator(crate::global_state::get_global_state_creator()) } diff --git a/examples/core/freezing_and_thawing/src/templates/about.rs b/examples/core/freezing_and_thawing/src/templates/about.rs index 180cc30a8b..95dc7ce29b 100644 --- a/examples/core/freezing_and_thawing/src/templates/about.rs +++ b/examples/core/freezing_and_thawing/src/templates/about.rs @@ -1,5 +1,4 @@ use perseus::prelude::*; -use perseus::state::Freeze; use sycamore::prelude::*; use crate::global_state::AppStateRx; @@ -8,7 +7,7 @@ fn about_page<G: Html>(cx: Scope) -> View<G> { // This is not part of our data model, we do NOT want the frozen app // synchronized as part of our page's state, it should be separate let frozen_app = create_signal(cx, String::new()); - let render_ctx = RenderCtx::from_ctx(cx); + let render_ctx = Reactor::<G>::from_cx(cx); let global_state = render_ctx.get_global_state::<AppStateRx>(cx); @@ -21,12 +20,16 @@ fn about_page<G: Html>(cx: Scope) -> View<G> { // We'll let the user freeze from here to demonstrate that the frozen state also navigates back to the last route button(id = "freeze_button", on:click = |_| { - frozen_app.set(render_ctx.freeze()); + #[cfg(target_arch = "wasm32")] + { + use perseus::state::Freeze; + frozen_app.set(render_ctx.freeze()); + } }) { "Freeze!" } p(id = "frozen_app") { (frozen_app.get()) } } } pub fn get_template<G: Html>() -> Template<G> { - Template::new("about").template(about_page) + Template::build("about").view(about_page).build() } diff --git a/examples/core/freezing_and_thawing/src/templates/index.rs b/examples/core/freezing_and_thawing/src/templates/index.rs index 30282d89d7..035bd851ae 100644 --- a/examples/core/freezing_and_thawing/src/templates/index.rs +++ b/examples/core/freezing_and_thawing/src/templates/index.rs @@ -1,22 +1,21 @@ use crate::global_state::AppStateRx; -use perseus::{prelude::*, state::Freeze}; +use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] -#[rx(alias = "IndexPropsRx")] -struct IndexProps { +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "IndexPageStateRx")] +struct IndexPageState { username: String, } -#[perseus::template] -fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPropsRx<'a>) -> View<G> { +fn index_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a IndexPageStateRx) -> View<G> { // This is not part of our data model, we do NOT want the frozen app // synchronized as part of our page's state, it should be separate let frozen_app = create_signal(cx, String::new()); - let render_ctx = RenderCtx::from_ctx(cx); + let reactor = Reactor::<G>::from_cx(cx); - let global_state = render_ctx.get_global_state::<AppStateRx>(cx); + let global_state = reactor.get_global_state::<AppStateRx>(cx); view! { cx, // For demonstration, we'll let the user modify the page's state and the global state arbitrarily @@ -30,13 +29,18 @@ fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPropsRx<'a>) -> View<G> { br() button(id = "freeze_button", on:click = |_| { - frozen_app.set(render_ctx.freeze()); + #[cfg(target_arch = "wasm32")] + { + use perseus::state::Freeze; + frozen_app.set(reactor.freeze()); + } }) { "Freeze!" } p(id = "frozen_app") { (frozen_app.get()) } input(id = "thaw_input", bind:value = frozen_app, placeholder = "Frozen state") button(id = "thaw_button", on:click = |_| { - render_ctx.thaw(&frozen_app.get(), perseus::state::ThawPrefs { + #[cfg(target_arch = "wasm32")] + reactor.thaw(&frozen_app.get(), perseus::state::ThawPrefs { page: perseus::state::PageThawPrefs::IncludeAll, global_prefer_frozen: true }).unwrap(); @@ -45,14 +49,15 @@ fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPropsRx<'a>) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index") + Template::build("index") .build_state_fn(get_build_state) - .template_with_state(index_page) + .view_with_state(index_page) + .build() } #[engine_only_fn] -async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<IndexProps> { - Ok(IndexProps { +async fn get_build_state(_info: StateGeneratorInfo<()>) -> IndexPageState { + IndexPageState { username: "".to_string(), - }) + } } diff --git a/examples/core/freezing_and_thawing/tests/main.rs b/examples/core/freezing_and_thawing/tests/main.rs index 1d157e9d88..fafa8e3844 100644 --- a/examples/core/freezing_and_thawing/tests/main.rs +++ b/examples/core/freezing_and_thawing/tests/main.rs @@ -7,7 +7,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { wait_for_checkpoint!("begin", 0, c); let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); - wait_for_checkpoint!("initial_state_present", 0, c); wait_for_checkpoint!("page_interactive", 0, c); // Check the initials @@ -42,7 +41,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { wait_for_checkpoint!("begin", 0, c); let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); - wait_for_checkpoint!("initial_state_present", 0, c); wait_for_checkpoint!("page_interactive", 0, c); // Check that the empty initials are restored assert_eq!( diff --git a/examples/core/global_state/src/error_pages.rs b/examples/core/global_state/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/global_state/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/global_state/src/global_state.rs b/examples/core/global_state/src/global_state.rs index 2bb2a45f9e..4219fb2577 100644 --- a/examples/core/global_state/src/global_state.rs +++ b/examples/core/global_state/src/global_state.rs @@ -14,13 +14,18 @@ pub struct AppState { pub test: String, } +// All the below functions can return either `AppState`, or `Result<AppState, +// E>`, where `E` is some error type. For concision, these examples cannot +// return errors. Request state and state amalgamation use `BlamedError`s if +// they're fallible. + // Global state will be generated for each locale in your app (but we don't // worry about that in this example) #[engine_only_fn] -async fn get_build_state(_locale: String) -> RenderFnResult<AppState> { - Ok(AppState { +async fn get_build_state(_locale: String) -> AppState { + AppState { test: "Hello from the build process!".to_string(), - }) + } } // This will be executed every time there's a request to any page in your app @@ -29,10 +34,10 @@ async fn get_build_state(_locale: String) -> RenderFnResult<AppState> { // prevent your app from accessing global state during the build process, so be // certain that's what you want if you go down that path. #[engine_only_fn] -async fn get_request_state(_locale: String, _req: Request) -> RenderFnResultWithCause<AppState> { - Ok(AppState { +async fn get_request_state(_locale: String, _req: Request) -> AppState { + AppState { test: "Hello from the server!".to_string(), - }) + } } // You can even combine build state with request state, just like in a template! @@ -41,11 +46,11 @@ async fn amalgamate_states( _locale: String, build_state: AppState, request_state: AppState, -) -> RenderFnResultWithCause<AppState> { - Ok(AppState { +) -> AppState { + AppState { test: format!( "Message from the builder: '{}' Message from the server: '{}'", build_state.test, request_state.test, ), - }) + } } diff --git a/examples/core/global_state/src/main.rs b/examples/core/global_state/src/main.rs index f4b5c158f5..b710ecc221 100644 --- a/examples/core/global_state/src/main.rs +++ b/examples/core/global_state/src/main.rs @@ -1,14 +1,13 @@ -mod error_pages; mod global_state; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) .template(crate::templates::about::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) .global_state_creator(crate::global_state::get_global_state_creator()) } diff --git a/examples/core/global_state/src/templates/about.rs b/examples/core/global_state/src/templates/about.rs index 362749dcb6..6b819d8b2c 100644 --- a/examples/core/global_state/src/templates/about.rs +++ b/examples/core/global_state/src/templates/about.rs @@ -4,7 +4,7 @@ use sycamore::prelude::*; use crate::global_state::AppStateRx; fn about_page<G: Html>(cx: Scope) -> View<G> { - let global_state = RenderCtx::from_ctx(cx).get_global_state::<AppStateRx>(cx); + let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx); view! { cx, // The user can change the global state through an input, and the changes they make will be reflected throughout the app @@ -23,5 +23,5 @@ fn head(cx: Scope) -> View<SsrNode> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("about").template(about_page).head(head) + Template::build("about").view(about_page).head(head).build() } diff --git a/examples/core/global_state/src/templates/index.rs b/examples/core/global_state/src/templates/index.rs index 78040d0ec2..3ac3568c62 100644 --- a/examples/core/global_state/src/templates/index.rs +++ b/examples/core/global_state/src/templates/index.rs @@ -7,7 +7,7 @@ use sycamore::prelude::*; fn index_page<G: Html>(cx: Scope) -> View<G> { // We access the global state through the render context, extracted from // Sycamore's context system - let global_state = RenderCtx::from_ctx(cx).get_global_state::<AppStateRx>(cx); + let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx); view! { cx, // The user can change the global state through an input, and the changes they make will be reflected throughout the app @@ -26,5 +26,5 @@ fn head(cx: Scope) -> View<SsrNode> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index").template(index_page).head(head) + Template::build("index").view(index_page).head(head).build() } diff --git a/examples/core/global_state/tests/main.rs b/examples/core/global_state/tests/main.rs index cc81e0b66b..c4de1b7ee0 100644 --- a/examples/core/global_state/tests/main.rs +++ b/examples/core/global_state/tests/main.rs @@ -7,7 +7,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { wait_for_checkpoint!("begin", 0, c); let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); - wait_for_checkpoint!("initial_state_present", 0, c); wait_for_checkpoint!("page_interactive", 0, c); // The initial text should be "Hello World!" @@ -25,7 +24,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { c.find(Locator::Id("about-link")).await?.click().await?; let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080/about")); - wait_for_checkpoint!("initial_state_not_present", 0, c); wait_for_checkpoint!("page_interactive", 1, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; diff --git a/examples/core/helper_build_state/README.md b/examples/core/helper_build_state/README.md index 441ba5996c..10939e48be 100644 --- a/examples/core/helper_build_state/README.md +++ b/examples/core/helper_build_state/README.md @@ -9,3 +9,5 @@ With helper state, you can read all the series once in build paths, creating per This feature falls into the more advanced category of Perseus features, but it's certainly not difficult to use. Generally, it's better to use this feature when you need it, rather than trying to use it where you might not: when you do need it, it tends to be fairly obvious when you realise that the only other way is to perform the same operation over and over again. Another nice feature of helper state is that it's engine-only, which means, no matter how large your helper state, it won't impact the size of your final binary! (Sometimes, you might even want to put the contents of every single blog post in that helper state, if you need to read each file in build paths anyway.) Note however that it will be serialized to a file and read for request state in apps using a server, so it shouldn't be *massive*, or this process might become a little slow, which would jeopardise load times (again, this is only a concern with very large state in non-exported apps). + +*Note: internally, this example also functions as an important canary on the feature of Perseus that is most likely to be broken by code changes: index-level build paths. If any part of these tests fails with a 'timeout waiting on condition' error, there is almost certainly a missing `/` strip somewhere in the code. This is what we have tests for!* diff --git a/examples/core/helper_build_state/src/error_pages.rs b/examples/core/helper_build_state/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/helper_build_state/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/helper_build_state/src/main.rs b/examples/core/helper_build_state/src/main.rs index 87ed16df4a..36895e1bb6 100644 --- a/examples/core/helper_build_state/src/main.rs +++ b/examples/core/helper_build_state/src/main.rs @@ -1,11 +1,10 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) } diff --git a/examples/core/helper_build_state/src/templates/index.rs b/examples/core/helper_build_state/src/templates/index.rs index d93ab0d089..1e71f68bc2 100644 --- a/examples/core/helper_build_state/src/templates/index.rs +++ b/examples/core/helper_build_state/src/templates/index.rs @@ -2,22 +2,19 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[template] -fn index_page<'a, G: Html>(cx: Scope<'a>, state: PageStateRx<'a>) -> View<G> { - let title = state.title; - let content = state.content; +fn index_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View<G> { view! { cx, h1 { - (title.get()) + (state.title.get()) } p { - (content.get()) + (state.content.get()) } } } // This is our page state, so it does have to be either reactive or unreactive -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] struct PageState { title: String, @@ -34,9 +31,7 @@ struct HelperState(String); // type parameter is the type of your helper state! (Make sure you don't confuse // this with your *template* state!) #[engine_only_fn] -async fn get_build_state( - info: StateGeneratorInfo<HelperState>, -) -> RenderFnResultWithCause<PageState> { +async fn get_build_state(info: StateGeneratorInfo<HelperState>) -> PageState { let title = format!("Path: {}", &info.path); let content = format!( "This post's original slug was '{}'. Extra state: {}", @@ -45,12 +40,12 @@ async fn get_build_state( info.get_extra().0, ); - Ok(PageState { title, content }) + PageState { title, content } } #[engine_only_fn] -async fn get_build_paths() -> RenderFnResult<BuildPaths> { - Ok(BuildPaths { +async fn get_build_paths() -> BuildPaths { + BuildPaths { paths: vec![ "".to_string(), "test".to_string(), @@ -60,12 +55,13 @@ async fn get_build_paths() -> RenderFnResult<BuildPaths> { // which can handle *any* owned type you give it! Hence, we need to pop a `.into()` // on the end of this. extra: HelperState("extra helper state!".to_string()).into(), - }) + } } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index") - .template_with_state(index_page) + Template::build("index") + .view_with_state(index_page) .build_state_fn(get_build_state) .build_paths_fn(get_build_paths) + .build() } diff --git a/examples/core/helper_build_state/tests/main.rs b/examples/core/helper_build_state/tests/main.rs index 3c3d358ac4..12396546ed 100644 --- a/examples/core/helper_build_state/tests/main.rs +++ b/examples/core/helper_build_state/tests/main.rs @@ -8,17 +8,17 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { c: &mut Client, ) -> Result<(), fantoccini::error::CmdError> { c.goto(&format!("http://localhost:8080/{}", page)).await?; - wait_for_checkpoint!("initial_state_present", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); // There should be a heading with the slug let heading = c.find(Locator::Css("h1")).await?.text().await?; - dbg!(&heading); - assert!(heading.contains(&format!("Path: {}", page))); + assert!(heading.contains(&format!("Path: {}", page).trim())); // The helper state should be the same on every page let text = c.find(Locator::Css("p")).await?.text().await?; assert!(text.contains("Extra state: extra helper state!")); Ok(()) } + test_build_path("", c).await?; test_build_path("test", c).await?; test_build_path("blah/test/blah", c).await?; diff --git a/examples/core/i18n/src/error_pages.rs b/examples/core/i18n/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/i18n/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/i18n/src/main.rs b/examples/core/i18n/src/main.rs index d3d10c9fea..1ae21c7368 100644 --- a/examples/core/i18n/src/main.rs +++ b/examples/core/i18n/src/main.rs @@ -1,7 +1,6 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { @@ -9,6 +8,6 @@ pub fn main<G: Html>() -> PerseusApp<G> { .template(crate::templates::index::get_template()) .template(crate::templates::about::get_template()) .template(crate::templates::post::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) .locales_and_translations_manager("en-US", &["fr-FR", "es-ES"]) } diff --git a/examples/core/i18n/src/templates/about.rs b/examples/core/i18n/src/templates/about.rs index 2a8ff04ab6..65d151c369 100644 --- a/examples/core/i18n/src/templates/about.rs +++ b/examples/core/i18n/src/templates/about.rs @@ -1,5 +1,5 @@ -use perseus::{t, Template}; -use sycamore::prelude::{view, Html, Scope, View}; +use perseus::prelude::*; +use sycamore::prelude::*; fn about_page<G: Html>(cx: Scope) -> View<G> { view! { cx, @@ -17,5 +17,5 @@ fn about_page<G: Html>(cx: Scope) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("about").template(about_page) + Template::build("about").view(about_page).build() } diff --git a/examples/core/i18n/src/templates/index.rs b/examples/core/i18n/src/templates/index.rs index 484572e902..e432a75e0c 100644 --- a/examples/core/i18n/src/templates/index.rs +++ b/examples/core/i18n/src/templates/index.rs @@ -1,5 +1,5 @@ -use perseus::{link, t, Template}; -use sycamore::prelude::{view, Html, Scope, View}; +use perseus::prelude::*; +use sycamore::prelude::*; fn index_page<G: Html>(cx: Scope) -> View<G> { let username = "User"; @@ -13,5 +13,5 @@ fn index_page<G: Html>(cx: Scope) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index").template(index_page) + Template::build("index").view(index_page).build() } diff --git a/examples/core/i18n/src/templates/post.rs b/examples/core/i18n/src/templates/post.rs index ad8075e332..1029ab85c5 100644 --- a/examples/core/i18n/src/templates/post.rs +++ b/examples/core/i18n/src/templates/post.rs @@ -2,23 +2,20 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PostPageStateRx")] struct PostPageState { title: String, content: String, } -#[perseus::template] -fn post_page<'a, G: Html>(cx: Scope<'a>, props: PostPageStateRx<'a>) -> View<G> { - let title = props.title; - let content = props.content; +fn post_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, props: &'a PostPageStateRx) -> View<G> { view! { cx, h1 { - (title.get()) + (props.title.get()) } p { - (content.get()) + (props.content.get()) } a(href = link!("/post", cx)) { "Root post page" } br() @@ -27,14 +24,15 @@ fn post_page<'a, G: Html>(cx: Scope<'a>, props: PostPageStateRx<'a>) -> View<G> } pub fn get_template<G: Html>() -> Template<G> { - Template::new("post") + Template::build("post") .build_paths_fn(get_build_paths) .build_state_fn(get_build_state) - .template_with_state(post_page) + .view_with_state(post_page) + .build() } #[engine_only_fn] -async fn get_build_state(info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<PostPageState> { +async fn get_build_state(info: StateGeneratorInfo<()>) -> PostPageState { // This is just an example let title = urlencoding::decode(&info.path).unwrap(); let content = format!( @@ -42,15 +40,15 @@ async fn get_build_state(info: StateGeneratorInfo<()>) -> RenderFnResultWithCaus title, info.path ); - Ok(PostPageState { + PostPageState { title: title.to_string(), content, - }) + } } #[engine_only_fn] -async fn get_build_paths() -> RenderFnResult<BuildPaths> { - Ok(BuildPaths { +async fn get_build_paths() -> BuildPaths { + BuildPaths { paths: vec![ "".to_string(), "test".to_string(), @@ -58,5 +56,5 @@ async fn get_build_paths() -> RenderFnResult<BuildPaths> { ], // We're not using any extra helper state extra: ().into(), - }) + } } diff --git a/examples/core/i18n/tests/main.rs b/examples/core/i18n/tests/main.rs index 84fa31b09d..cf9a60413f 100644 --- a/examples/core/i18n/tests/main.rs +++ b/examples/core/i18n/tests/main.rs @@ -4,7 +4,7 @@ use perseus::wait_for_checkpoint; #[perseus::test] async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { c.goto("http://localhost:8080").await?; - wait_for_checkpoint!("router_entry", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); let url = c.current_url().await?; // We only test for one locale here because changing the browser's preferred // languages is very hard, we do unit testing on the locale detection system @@ -18,7 +18,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { c.find(Locator::Css("a")).await?.click().await?; // This tests i18n linking (locale should be auto-detected) let url = c.current_url().await?; - wait_for_checkpoint!("initial_state_not_present", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); assert!(url .as_ref() .starts_with("http://localhost:8080/en-US/about")); diff --git a/examples/core/idb_freezing/src/error_pages.rs b/examples/core/idb_freezing/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/idb_freezing/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/idb_freezing/src/global_state.rs b/examples/core/idb_freezing/src/global_state.rs index 7cbf31bfca..9be39d2886 100644 --- a/examples/core/idb_freezing/src/global_state.rs +++ b/examples/core/idb_freezing/src/global_state.rs @@ -12,8 +12,8 @@ pub struct AppState { } #[engine_only_fn] -async fn get_build_state(_locale: String) -> RenderFnResult<AppState> { - Ok(AppState { +async fn get_build_state(_locale: String) -> AppState { + AppState { test: "Hello World!".to_string(), - }) + } } diff --git a/examples/core/idb_freezing/src/main.rs b/examples/core/idb_freezing/src/main.rs index f4b5c158f5..b710ecc221 100644 --- a/examples/core/idb_freezing/src/main.rs +++ b/examples/core/idb_freezing/src/main.rs @@ -1,14 +1,13 @@ -mod error_pages; mod global_state; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) .template(crate::templates::about::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) .global_state_creator(crate::global_state::get_global_state_creator()) } diff --git a/examples/core/idb_freezing/src/templates/about.rs b/examples/core/idb_freezing/src/templates/about.rs index 04053a4f34..a2a373d8bc 100644 --- a/examples/core/idb_freezing/src/templates/about.rs +++ b/examples/core/idb_freezing/src/templates/about.rs @@ -9,8 +9,8 @@ fn about_page<G: Html>(cx: Scope) -> View<G> { // It's faster to get this only once and rely on reactivity // But it's unused when this runs on the server-side because of the target-gate // below - let render_ctx = RenderCtx::from_ctx(cx); - let global_state = render_ctx.get_global_state::<AppStateRx>(cx); + let reactor = Reactor::<G>::from_cx(cx); + let global_state = reactor.get_global_state::<AppStateRx>(cx); view! { cx, p(id = "global_state") { (global_state.test.get()) } @@ -23,10 +23,10 @@ fn about_page<G: Html>(cx: Scope) -> View<G> { button(id = "freeze_button", on:click = move |_| { // The IndexedDB API is asynchronous, so we'll spawn a future #[cfg(target_arch = "wasm32")] - perseus::spawn_local_scoped(cx, async move { + spawn_local_scoped(cx, async move { use perseus::state::{IdbFrozenStateStore, Freeze}; // We do this here (rather than when we get the render context) so that it's updated whenever we press the button - let frozen_state = render_ctx.freeze(); + let frozen_state = reactor.freeze(); let idb_store = match IdbFrozenStateStore::new().await { Ok(idb_store) => idb_store, Err(_) => { @@ -45,5 +45,5 @@ fn about_page<G: Html>(cx: Scope) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("about").template(about_page) + Template::build("about").view(about_page).build() } diff --git a/examples/core/idb_freezing/src/templates/index.rs b/examples/core/idb_freezing/src/templates/index.rs index 0464b1ec32..8740a4d855 100644 --- a/examples/core/idb_freezing/src/templates/index.rs +++ b/examples/core/idb_freezing/src/templates/index.rs @@ -3,22 +3,21 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "IndexPropsRx")] struct IndexProps { username: String, } -#[perseus::template] -fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPropsRx<'a>) -> View<G> { +fn index_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a IndexPropsRx) -> View<G> { // This is not part of our data model let freeze_status = create_signal(cx, String::new()); let thaw_status = create_signal(cx, String::new()); // It's faster to get this only once and rely on reactivity // But it's unused when this runs on the server-side because of the target-gate // below - let render_ctx = RenderCtx::from_ctx(cx); - let global_state = render_ctx.get_global_state::<AppStateRx>(cx); + let reactor = Reactor::<G>::from_cx(cx); + let global_state = reactor.get_global_state::<AppStateRx>(cx); view! { cx, // For demonstration, we'll let the user modify the page's state and the global state arbitrarily @@ -34,10 +33,10 @@ fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPropsRx<'a>) -> View<G> { button(id = "freeze_button", on:click = move |_| { // The IndexedDB API is asynchronous, so we'll spawn a future #[cfg(target_arch = "wasm32")] // The freezing types are only available in the browser - perseus::spawn_local_scoped(cx, async { + spawn_local_scoped(cx, async { use perseus::state::{IdbFrozenStateStore, Freeze, PageThawPrefs, ThawPrefs}; - // We do this here (rather than when we get the render context) so that it's updated whenever we press the button - let frozen_state = render_ctx.freeze(); + // We do this here (rather than when we get the reactor) so that it's updated whenever we press the button + let frozen_state = reactor.freeze(); let idb_store = match IdbFrozenStateStore::new().await { Ok(idb_store) => idb_store, Err(_) => { @@ -56,7 +55,7 @@ fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPropsRx<'a>) -> View<G> { button(id = "thaw_button", on:click = move |_| { // The IndexedDB API is asynchronous, so we'll spawn a future #[cfg(target_arch = "wasm32")] // The freezing types are only available in the browser - perseus::spawn_local_scoped(cx, async move { + spawn_local_scoped(cx, async move { use perseus::state::{IdbFrozenStateStore, Freeze, PageThawPrefs, ThawPrefs}; let idb_store = match IdbFrozenStateStore::new().await { Ok(idb_store) => idb_store, @@ -78,7 +77,7 @@ fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPropsRx<'a>) -> View<G> { }; // You would probably set your thawing preferences differently - match render_ctx.thaw(&frozen_state, ThawPrefs { page: PageThawPrefs::IncludeAll, global_prefer_frozen: true }) { + match reactor.thaw(&frozen_state, ThawPrefs { page: PageThawPrefs::IncludeAll, global_prefer_frozen: true }) { Ok(_) => thaw_status.set("Thawed.".to_string()), Err(_) => thaw_status.set("Error.".to_string()) } @@ -89,14 +88,15 @@ fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPropsRx<'a>) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index") + Template::build("index") .build_state_fn(get_build_state) - .template_with_state(index_page) + .view_with_state(index_page) + .build() } #[engine_only_fn] -async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<IndexProps> { - Ok(IndexProps { +async fn get_build_state(_info: StateGeneratorInfo<()>) -> IndexProps { + IndexProps { username: "".to_string(), - }) + } } diff --git a/examples/core/idb_freezing/tests/main.rs b/examples/core/idb_freezing/tests/main.rs index 50f30006db..3acfa20199 100644 --- a/examples/core/idb_freezing/tests/main.rs +++ b/examples/core/idb_freezing/tests/main.rs @@ -7,7 +7,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { wait_for_checkpoint!("begin", 0, c); let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); - wait_for_checkpoint!("initial_state_present", 0, c); wait_for_checkpoint!("page_interactive", 0, c); // Check the initials @@ -42,7 +41,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { wait_for_checkpoint!("begin", 0, c); let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); - wait_for_checkpoint!("initial_state_present", 0, c); wait_for_checkpoint!("page_interactive", 0, c); // Check that the empty initials are restored assert_eq!( diff --git a/examples/core/index_view/src/error_pages.rs b/examples/core/index_view/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/index_view/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/index_view/src/main.rs b/examples/core/index_view/src/main.rs index 6410e6086f..92ab8b3637 100644 --- a/examples/core/index_view/src/main.rs +++ b/examples/core/index_view/src/main.rs @@ -1,14 +1,13 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp, PerseusRoot}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) .template(crate::templates::about::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) .index_view(|cx| { sycamore::view! { cx, // We don't need a `<!DOCTYPE html>`, that's added automatically by Perseus (though that can be overridden if you really want by using `.index_view_str()`) diff --git a/examples/core/index_view/src/templates/about.rs b/examples/core/index_view/src/templates/about.rs index 36cbdf5a3b..7f0edc7c19 100644 --- a/examples/core/index_view/src/templates/about.rs +++ b/examples/core/index_view/src/templates/about.rs @@ -9,5 +9,5 @@ fn about_page<G: Html>(cx: Scope) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("about").template(about_page) + Template::build("about").view(about_page).build() } diff --git a/examples/core/index_view/src/templates/index.rs b/examples/core/index_view/src/templates/index.rs index fa4ad24a8a..664d2c48bc 100644 --- a/examples/core/index_view/src/templates/index.rs +++ b/examples/core/index_view/src/templates/index.rs @@ -9,5 +9,5 @@ fn index_page<G: Html>(cx: Scope) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index").template(index_page) + Template::build("index").view(index_page).build() } diff --git a/examples/core/index_view/tests/main.rs b/examples/core/index_view/tests/main.rs index f846135f89..3be7116ba1 100644 --- a/examples/core/index_view/tests/main.rs +++ b/examples/core/index_view/tests/main.rs @@ -9,7 +9,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { assert!(url.as_ref().starts_with("http://localhost:8080")); // The greeting was passed through using build state - wait_for_checkpoint!("initial_state_present", 0, c); wait_for_checkpoint!("page_interactive", 0, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Hello World!"); @@ -21,7 +20,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { c.find(Locator::Id("about-link")).await?.click().await?; let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080/about")); - wait_for_checkpoint!("initial_state_not_present", 0, c); wait_for_checkpoint!("page_interactive", 1, c); // Make sure the hardcoded text there exists let text = c.find(Locator::Css("p")).await?.text().await?; @@ -29,9 +27,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { // Check the footer, the symbol of the index view having worked let footer = c.find(Locator::Css("footer")).await?.text().await?; assert_eq!(footer, "This is a footer!"); - // Make sure we get initial state if we refresh - c.refresh().await?; - wait_for_checkpoint!("initial_state_present", 0, c); Ok(()) } diff --git a/examples/core/js_interop/src/error_pages.rs b/examples/core/js_interop/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/js_interop/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/js_interop/src/main.rs b/examples/core/js_interop/src/main.rs index 87ed16df4a..36895e1bb6 100644 --- a/examples/core/js_interop/src/main.rs +++ b/examples/core/js_interop/src/main.rs @@ -1,11 +1,10 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) } diff --git a/examples/core/js_interop/src/templates/index.rs b/examples/core/js_interop/src/templates/index.rs index 302f314f06..9059fe252d 100644 --- a/examples/core/js_interop/src/templates/index.rs +++ b/examples/core/js_interop/src/templates/index.rs @@ -1,5 +1,5 @@ -use perseus::{Html, Template}; -use sycamore::prelude::{view, Scope, View}; +use perseus::prelude::*; +use sycamore::prelude::*; #[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::wasm_bindgen; @@ -15,7 +15,7 @@ fn index_page<G: Html>(cx: Scope) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index").template(index_page) + Template::build("index").view(index_page).build() } // Of course, JS will only run in the browser, so this should be browser-only diff --git a/examples/core/js_interop/tests/main.rs b/examples/core/js_interop/tests/main.rs index 4b1547313a..425a3b4df3 100644 --- a/examples/core/js_interop/tests/main.rs +++ b/examples/core/js_interop/tests/main.rs @@ -9,29 +9,13 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { assert!(url.as_ref().starts_with("http://localhost:8080")); // The greeting was passed through using build state - wait_for_checkpoint!("initial_state_present", 0, c); wait_for_checkpoint!("page_interactive", 0, c); - let greeting = c.find(Locator::Css("p")).await?.text().await?; - assert_eq!(greeting, "Hello World!"); - // For some reason, retrieving the inner HTML or text of a `<title>` doesn't - // work - let title = c.find(Locator::Css("title")).await?.html(false).await?; - assert!(title.contains("Index Page")); + let mut greeting = c.find(Locator::Css("p")).await?; + assert_eq!(greeting.text().await?, "Hello World!"); - // Go to `/about` - c.find(Locator::Id("about-link")).await?.click().await?; - let url = c.current_url().await?; - assert!(url.as_ref().starts_with("http://localhost:8080/about")); - wait_for_checkpoint!("initial_state_not_present", 0, c); - wait_for_checkpoint!("page_interactive", 1, c); - // Make sure the hardcoded text there exists - let text = c.find(Locator::Css("p")).await?.text().await?; - assert_eq!(text, "About."); - let title = c.find(Locator::Css("title")).await?.html(false).await?; - assert!(title.contains("About Page")); - // Make sure we get initial state if we refresh - c.refresh().await?; - wait_for_checkpoint!("initial_state_present", 0, c); + let change_msg_button = c.find(Locator::Id("change-message")).await?; + change_msg_button.click().await?; + assert_eq!(greeting.text().await?, "Message from JS!"); Ok(()) } diff --git a/examples/core/plugins/src/error_pages.rs b/examples/core/plugins/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/plugins/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/plugins/src/main.rs b/examples/core/plugins/src/main.rs index 66e6868f04..6cff2267e8 100644 --- a/examples/core/plugins/src/main.rs +++ b/examples/core/plugins/src/main.rs @@ -1,19 +1,17 @@ -mod error_pages; mod plugin; mod templates; -use perseus::{plugins::Plugins, Html, PerseusApp}; +use perseus::{plugins::Plugins, prelude::*}; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) - .template(crate::templates::about::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) .plugins(Plugins::new().plugin_with_client_privilege( plugin::get_test_plugin, plugin::TestPluginData { - about_page_greeting: "Hey from a plugin!".to_string(), + console_greeting: "Hey from a plugin!".to_string(), }, )) } diff --git a/examples/core/plugins/src/plugin.rs b/examples/core/plugins/src/plugin.rs index 1ccc0fddc5..585d134f3b 100644 --- a/examples/core/plugins/src/plugin.rs +++ b/examples/core/plugins/src/plugin.rs @@ -1,15 +1,15 @@ use perseus::plugins::{empty_control_actions_registrar, Plugin, PluginAction, PluginEnv}; -use perseus::Template; #[derive(Debug)] pub struct TestPluginData { - pub about_page_greeting: String, + pub console_greeting: String, } -pub fn get_test_plugin<G: perseus::Html>() -> Plugin<G, TestPluginData> { +pub fn get_test_plugin() -> Plugin<TestPluginData> { Plugin::new( "test-plugin", |mut actions| { + // Add a static alias for `Cargo.toml` actions .settings_actions .add_static_aliases @@ -18,24 +18,21 @@ pub fn get_test_plugin<G: perseus::Html>() -> Plugin<G, TestPluginData> { map.insert("/Cargo.toml".to_string(), "Cargo.toml".to_string()); Ok(map) }); - actions.settings_actions.add_templates.register_plugin( - "test-plugin", - |_, plugin_data| { - if let Some(plugin_data) = plugin_data.downcast_ref::<TestPluginData>() { - let about_page_greeting = plugin_data.about_page_greeting.to_string(); - Ok(vec![Template::new("about").template(move |cx| { - sycamore::view! { cx, p { (about_page_greeting) } } - })]) - } else { - unreachable!() - } - }, - ); + // Log the greeting the user provided when the app starts up + actions + .client_actions + .start + .register_plugin("test-plugin", |_, data| { + // Perseus can't do this for you just yet, but you can always `.unwrap()` + let data = data.downcast_ref::<TestPluginData>().unwrap(); + perseus::web_log!("{}", data.console_greeting); + Ok(()) + }); actions.tinker.register_plugin("test-plugin", |_, _| { println!("{:?}", std::env::current_dir().unwrap()); // This is completely pointless, but demonstrates how plugin dependencies can // blow up binary sizes if they aren't made tinker-only plugins - let test = "[package]\name = \"test\""; + let test = "[package]\nname = \"test\""; let parsed: toml::Value = toml::from_str(test).unwrap(); println!("{}", toml::to_string(&parsed).unwrap()); Ok(()) diff --git a/examples/core/plugins/src/templates/about.rs b/examples/core/plugins/src/templates/about.rs deleted file mode 100644 index e2eae1f2b3..0000000000 --- a/examples/core/plugins/src/templates/about.rs +++ /dev/null @@ -1,20 +0,0 @@ -use perseus::prelude::*; -use sycamore::prelude::*; - -// This page will actually be replaced entirely by a plugin! -fn about_page<G: Html>(cx: Scope) -> View<G> { - view! { cx, - p { "About." } - } -} - -#[engine_only_fn] -fn head(cx: Scope) -> View<SsrNode> { - view! { cx, - title { "About Page | Perseus Example – Plugins" } - } -} - -pub fn get_template<G: Html>() -> Template<G> { - Template::new("about").template(about_page).head(head) -} diff --git a/examples/core/plugins/src/templates/index.rs b/examples/core/plugins/src/templates/index.rs index dc89c5d9e8..16707be6b7 100644 --- a/examples/core/plugins/src/templates/index.rs +++ b/examples/core/plugins/src/templates/index.rs @@ -4,7 +4,6 @@ use sycamore::prelude::*; fn index_page<G: Html>(cx: Scope) -> View<G> { view! { cx, p { "Hello World!" } - a(href = "about", id = "about-link") { "About!" } } } @@ -16,5 +15,5 @@ fn head(cx: Scope) -> View<SsrNode> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index").template(index_page).head(head) + Template::build("index").view(index_page).head(head).build() } diff --git a/examples/core/plugins/src/templates/mod.rs b/examples/core/plugins/src/templates/mod.rs index 9b9cf18fc5..33edc959c9 100644 --- a/examples/core/plugins/src/templates/mod.rs +++ b/examples/core/plugins/src/templates/mod.rs @@ -1,2 +1 @@ -pub mod about; pub mod index; diff --git a/examples/core/plugins/tests/main.rs b/examples/core/plugins/tests/main.rs index 96a4e665a5..699455131c 100644 --- a/examples/core/plugins/tests/main.rs +++ b/examples/core/plugins/tests/main.rs @@ -10,8 +10,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); - // The greeting was passed through using build state - wait_for_checkpoint!("initial_state_present", 0, c); wait_for_checkpoint!("page_interactive", 0, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Hello World!"); @@ -20,18 +18,11 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let title = c.find(Locator::Css("title")).await?.html(false).await?; assert!(title.contains("Index Page")); - // Go to `/about`, which should've been modified by a plugin - c.find(Locator::Id("about-link")).await?.click().await?; - let url = c.current_url().await?; - assert!(url.as_ref().starts_with("http://localhost:8080/about")); - wait_for_checkpoint!("initial_state_not_present", 0, c); - wait_for_checkpoint!("page_interactive", 1, c); - // Make sure the hardcoded text there exists - let text = c.find(Locator::Css("p")).await?.text().await?; - assert_eq!(text, "Hey from a plugin!"); - // Make sure we get initial state if we refresh - c.refresh().await?; - wait_for_checkpoint!("initial_state_present", 0, c); + // BUG Right now, this is downloaded by the browser... + // // Check that the static alias to `Cargo.toml` worked (added by a plugin) + // c.goto("http://localhost:8080/Cargo.toml").await?; + // let text = c.find(Locator::Css("body")).await?.text().await?; + // assert!(text.starts_with("[package]")); Ok(()) } diff --git a/examples/core/preload/Cargo.toml b/examples/core/preload/Cargo.toml index f1d5ad03be..fdd8961270 100644 --- a/examples/core/preload/Cargo.toml +++ b/examples/core/preload/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -perseus = { path = "../../../packages/perseus", features = [ "hydrate" ] } +perseus = { path = "../../../packages/perseus", features = [ "hydrate", "translator-fluent" ] } sycamore = "^0.8.1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/examples/core/preload/README.md b/examples/core/preload/README.md index a1e523331f..6dc7689225 100644 --- a/examples/core/preload/README.md +++ b/examples/core/preload/README.md @@ -1,3 +1,5 @@ # Preload Example This example demonstrates Perseus' inbuilt imperative preloading functionality, which allows downloading all the assets needed to render a page ahead-of-time, so that, when the user reaches that page, they can go to it without any network requests being needed! + +This uses i18n to demonstrate that preloading handles multiple locales with ease. diff --git a/examples/core/preload/src/error_pages.rs b/examples/core/preload/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/preload/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/preload/src/main.rs b/examples/core/preload/src/main.rs index 02fdf9edfb..6b4b59649c 100644 --- a/examples/core/preload/src/main.rs +++ b/examples/core/preload/src/main.rs @@ -1,12 +1,12 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) .template(crate::templates::about::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) + .locales_and_translations_manager("en-US", &["fr-FR"]) } diff --git a/examples/core/preload/src/templates/about.rs b/examples/core/preload/src/templates/about.rs index 42a818efd2..4f94fdc09b 100644 --- a/examples/core/preload/src/templates/about.rs +++ b/examples/core/preload/src/templates/about.rs @@ -1,15 +1,15 @@ -use perseus::{Html, Template}; -use sycamore::prelude::{view, Scope}; +use perseus::prelude::*; +use sycamore::prelude::*; use sycamore::view::View; fn about_page<G: Html>(cx: Scope) -> View<G> { view! { cx, - p { "Check out your browser's network DevTools, no new requests were needed to get to this page!" } + p { (t!("about-msg", cx)) } - a(id = "index-link", href = "") { "Index" } + a(id = "index-link", href = link!("", cx)) { (t!("about-index-link", cx)) } } } pub fn get_template<G: Html>() -> Template<G> { - Template::new("about").template(about_page) + Template::build("about").view(about_page).build() } diff --git a/examples/core/preload/src/templates/index.rs b/examples/core/preload/src/templates/index.rs index fb60016416..0b0b5c9592 100644 --- a/examples/core/preload/src/templates/index.rs +++ b/examples/core/preload/src/templates/index.rs @@ -5,19 +5,26 @@ fn index_page<G: Html>(cx: Scope) -> View<G> { // We can't preload pages on the engine-side #[cfg(target_arch = "wasm32")] { - // Get the render context first, which is the one-stop-shop for everything + // Get the reactor first, which is the one-stop-shop for everything // internal to Perseus in the browser - let render_ctx = perseus::get_render_ctx!(cx); + let reactor = Reactor::<G>::from_cx(cx); // This spawns a future in the background, and will panic if the page you give // doesn't exist (to handle those errors and manage the future, use - // `.try_preload` instead) - render_ctx.preload(cx, "about"); + // `.try_preload` instead). + // + // Note that there is no `link!` macro here, and preloading is expressly + // disallowed across locales (i.e. you can only preload things in the + // current locale). This is to prevent unnecessary translations + // requests, which can be quite heavy. + reactor.preload(cx, "about"); } view! { cx, - p { "Open up your browser's DevTools, go to the network tab, and then click the link below..." } + p { (t!("index-msg", cx)) } - a(href = "about") { "About" } + a(href = link!("about", cx)) { (t!("index-about-link", cx)) } + a(href = "fr-FR/about") { "About (French)" } + a(href = "en-US/about") { "About (English)" } } } @@ -29,5 +36,5 @@ fn head(cx: Scope) -> View<SsrNode> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index").template(index_page).head(head) + Template::build("index").view(index_page).head(head).build() } diff --git a/examples/core/preload/translations/en-US.ftl b/examples/core/preload/translations/en-US.ftl new file mode 100644 index 0000000000..4eff5d98c6 --- /dev/null +++ b/examples/core/preload/translations/en-US.ftl @@ -0,0 +1,4 @@ +index-msg = Open up your browser's DevTools, go to the network tab, and then click the link below... +index-about-link = About +about-msg = Check out your browser's network DevTools, no new requests were needed to get to this page! +about-index-link = Index diff --git a/examples/core/preload/translations/fr-FR.ftl b/examples/core/preload/translations/fr-FR.ftl new file mode 100644 index 0000000000..d0ef17fa3f --- /dev/null +++ b/examples/core/preload/translations/fr-FR.ftl @@ -0,0 +1,5 @@ +# Blame DeepL, not me... +index-msg = Ouvrez les DevTools de votre navigateur, allez dans l'onglet réseau, puis cliquez sur le lien ci-dessous... +index-about-link = À propos de nous +about-msg = Ouvrez les DevTools du réseau de votre navigateur, aucune nouvelle requête n'a été nécessaire pour arriver à cette page! +about-index-link = Index diff --git a/examples/core/router_state/src/error_pages.rs b/examples/core/router_state/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/router_state/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/router_state/src/main.rs b/examples/core/router_state/src/main.rs index 02fdf9edfb..c33ac0cedb 100644 --- a/examples/core/router_state/src/main.rs +++ b/examples/core/router_state/src/main.rs @@ -1,12 +1,11 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) .template(crate::templates::about::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) } diff --git a/examples/core/router_state/src/templates/about.rs b/examples/core/router_state/src/templates/about.rs index ba3e328597..704915a64c 100644 --- a/examples/core/router_state/src/templates/about.rs +++ b/examples/core/router_state/src/templates/about.rs @@ -15,5 +15,5 @@ fn head(cx: Scope) -> View<SsrNode> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("about").template(about_page).head(head) + Template::build("about").view(about_page).head(head).build() } diff --git a/examples/core/router_state/src/templates/index.rs b/examples/core/router_state/src/templates/index.rs index 247383e253..06f76b938d 100644 --- a/examples/core/router_state/src/templates/index.rs +++ b/examples/core/router_state/src/templates/index.rs @@ -1,28 +1,40 @@ use perseus::prelude::*; -use perseus::router::RouterLoadState; use sycamore::prelude::*; fn router_state_page<G: Html>(cx: Scope) -> View<G> { - let load_state = RenderCtx::from_ctx(cx).router.get_load_state(cx); - // This uses Sycamore's `create_memo` to create a state that will update - // whenever the router state changes - let load_state_str = create_memo(cx, || match (*load_state.get()).clone() { - RouterLoadState::Loaded { - template_name, - path, - } => { - perseus::web_log!("Loaded."); - format!("Loaded {} (template: {}).", path, template_name) - } - RouterLoadState::Loading { - template_name, - path, - } => format!("Loading {} (template: {}).", path, template_name), - RouterLoadState::Server => "We're on the server.".to_string(), - // Since this code is running in a page, it's a little pointless to handle an error page, - // which would replace this page (we wouldn't be able to display anything if this happened) - RouterLoadState::ErrorLoaded { .. } => unreachable!(), - }); + let load_state_str = create_signal(cx, "We're on the server.".to_string()); + + #[cfg(target_arch = "wasm32")] + { + use perseus::router::RouterLoadState; + let load_state = Reactor::<G>::from_cx(cx).router_state.get_load_state(cx); + // This uses Sycamore's `create_memo` to create a state that will update + // whenever the router state changes + create_effect(cx, || { + let new_str = match (*load_state.get()).clone() { + RouterLoadState::Loaded { + template_name, + path, + } => { + perseus::web_log!("Loaded."); + // `path` is a `PathMaybeWithLocale`, a special Perseus type to indicate + // a path that will be prefixed with a locale if the app uses i18n, and + // not if it doesn't; it derefences to `&String`. + format!("Loaded {} (template: {}).", *path, template_name) + } + RouterLoadState::Loading { + template_name, + path, + } => format!("Loading {} (template: {}).", *path, template_name), + RouterLoadState::Server => "We're on the server.".to_string(), + // Since this code is running in a page, it's a little pointless to handle an error + // page, which would replace this page (we wouldn't be able to + // display anything if this happened) + RouterLoadState::ErrorLoaded { .. } => unreachable!(), + }; + load_state_str.set(new_str); + }); + } view! { cx, a(href = "about", id = "about-link") { "About!" } @@ -32,5 +44,5 @@ fn router_state_page<G: Html>(cx: Scope) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index").template(router_state_page) + Template::build("index").view(router_state_page).build() } diff --git a/examples/core/rx_state/src/error_pages.rs b/examples/core/rx_state/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/rx_state/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/rx_state/src/main.rs b/examples/core/rx_state/src/main.rs index 02fdf9edfb..c33ac0cedb 100644 --- a/examples/core/rx_state/src/main.rs +++ b/examples/core/rx_state/src/main.rs @@ -1,12 +1,11 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) .template(crate::templates::about::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) } diff --git a/examples/core/rx_state/src/templates/about.rs b/examples/core/rx_state/src/templates/about.rs index 6d53330210..b4810605a1 100644 --- a/examples/core/rx_state/src/templates/about.rs +++ b/examples/core/rx_state/src/templates/about.rs @@ -1,6 +1,5 @@ -use perseus::{Html, Template}; -use sycamore::prelude::{view, Scope}; -use sycamore::view::View; +use perseus::prelude::*; +use sycamore::prelude::*; fn about_page<G: Html>(cx: Scope) -> View<G> { view! { cx, @@ -11,5 +10,5 @@ fn about_page<G: Html>(cx: Scope) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("about").template(about_page) + Template::build("about").view(about_page).build() } diff --git a/examples/core/rx_state/src/templates/index.rs b/examples/core/rx_state/src/templates/index.rs index 11b8066367..52350f511a 100644 --- a/examples/core/rx_state/src/templates/index.rs +++ b/examples/core/rx_state/src/templates/index.rs @@ -2,7 +2,7 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "IndexPageStateRx")] struct IndexPageState { pub username: String, @@ -11,8 +11,7 @@ struct IndexPageState { // This macro will make our state reactive *and* store it in the page state // store, which means it'll be the same even if we go to the about page and come // back (as long as we're in the same session) -#[perseus::template] -fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPageStateRx<'a>) -> View<G> { +fn index_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a IndexPageStateRx) -> View<G> { view! { cx, p { (format!("Greetings, {}!", state.username.get())) } input(bind:value = state.username, placeholder = "Username") @@ -29,15 +28,16 @@ fn head(cx: Scope) -> View<SsrNode> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index") - .template_with_state(index_page) + Template::build("index") + .view_with_state(index_page) .head(head) .build_state_fn(get_build_state) + .build() } #[engine_only_fn] -async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<IndexPageState> { - Ok(IndexPageState { +async fn get_build_state(_info: StateGeneratorInfo<()>) -> IndexPageState { + IndexPageState { username: "".to_string(), - }) + } } diff --git a/examples/core/rx_state/tests/main.rs b/examples/core/rx_state/tests/main.rs index e3586007cc..a74d537947 100644 --- a/examples/core/rx_state/tests/main.rs +++ b/examples/core/rx_state/tests/main.rs @@ -7,7 +7,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { wait_for_checkpoint!("begin", 0, c); let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); - wait_for_checkpoint!("initial_state_present", 0, c); wait_for_checkpoint!("page_interactive", 0, c); // The initial greeting should be to an empty string @@ -25,12 +24,10 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { c.find(Locator::Id("about-link")).await?.click().await?; let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080/about")); - wait_for_checkpoint!("initial_state_not_present", 0, c); wait_for_checkpoint!("page_interactive", 1, c); c.find(Locator::Id("index-link")).await?.click().await?; let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); - wait_for_checkpoint!("initial_state_not_present", 0, c); wait_for_checkpoint!("page_interactive", 1, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; diff --git a/examples/core/set_headers/src/error_pages.rs b/examples/core/set_headers/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/set_headers/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/set_headers/src/main.rs b/examples/core/set_headers/src/main.rs index 87ed16df4a..36895e1bb6 100644 --- a/examples/core/set_headers/src/main.rs +++ b/examples/core/set_headers/src/main.rs @@ -1,11 +1,10 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) } diff --git a/examples/core/set_headers/src/templates/index.rs b/examples/core/set_headers/src/templates/index.rs index 45daf2c7bf..b39aa01889 100644 --- a/examples/core/set_headers/src/templates/index.rs +++ b/examples/core/set_headers/src/templates/index.rs @@ -2,14 +2,13 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] struct PageState { greeting: String, } -#[perseus::template] -fn index_page<'a, G: Html>(cx: Scope<'a>, state: PageStateRx<'a>) -> View<G> { +fn index_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View<G> { view! { cx, p { (state.greeting.get()) } } @@ -23,25 +22,31 @@ fn head(cx: Scope) -> View<SsrNode> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index") - .template_with_state(index_page) + Template::build("index") + .view_with_state(index_page) .head(head) .build_state_fn(get_build_state) - .set_headers_fn(set_headers) + // There is also `.set_headers()`, which takes a function that does not use the page state + .set_headers_with_state(set_headers) + .build() } #[engine_only_fn] -async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<PageState> { - Ok(PageState { +async fn get_build_state(_info: StateGeneratorInfo<()>) -> PageState { + PageState { greeting: "Hello World!".to_string(), - }) + } } // Unfortunately, this return type does have // to be fully qualified, or you have to import it with a server-only -// target-gate +// target-gate. +// +// This function takes a a scope so it can get at a `Reactor`, which will have +// the global state and, potentially, a translator. This can allow you to create +// localized headers. #[engine_only_fn] -fn set_headers(state: PageState) -> perseus::http::header::HeaderMap { +fn set_headers(_cx: Scope, state: PageState) -> perseus::http::header::HeaderMap { // These imports are only available on the server-side, which this function is // automatically gated to use perseus::http::header::{HeaderMap, HeaderName}; diff --git a/examples/core/state_generation/README.md b/examples/core/state_generation/README.md index 9451377f3c..cfb5c0b714 100644 --- a/examples/core/state_generation/README.md +++ b/examples/core/state_generation/README.md @@ -1,3 +1,7 @@ # State Generation Example This examples shows all the ways of generating template state in Perseus, with each file representing a different way of generating state. Though this example shows many concepts, it's practical to group them all together due to their fundamental connectedness. + +Note that all the state generation functions in Perseus, as well as several others, such as those responsible for generatint a page's `<head>` and settings its headers, can be either *fallible* or *infallible*. In other words, they can either return `T` (whatever their return type may be, e.g. `BuildPaths`, or perhaps `MyPageState`), or `Result<T, E>`, where `E` is some arbitrary error type. Throughout the Perseus examples, since errors are rarely shown, the infallible versions are shown, although this example uses the fallible versions to showcase `BlamedError`, a special type of error that can 'blame' either the client (if they caused the error), or the server. + +Functions that can either be fallible or infallible do not have to be specified with different functions on `Template` (unlike, say, `.template()` and `.template_with_state()`). diff --git a/examples/core/state_generation/src/error_pages.rs b/examples/core/state_generation/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/state_generation/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/state_generation/src/main.rs b/examples/core/state_generation/src/main.rs index 92ccd202d5..2b62921ead 100644 --- a/examples/core/state_generation/src/main.rs +++ b/examples/core/state_generation/src/main.rs @@ -1,7 +1,6 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { @@ -13,5 +12,5 @@ pub fn main<G: Html>() -> PerseusApp<G> { .template(crate::templates::revalidation::get_template()) .template(crate::templates::revalidation_and_incremental_generation::get_template()) .template(crate::templates::amalgamation::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) } diff --git a/examples/core/state_generation/src/templates/amalgamation.rs b/examples/core/state_generation/src/templates/amalgamation.rs index 2a93a90302..c7b373511d 100644 --- a/examples/core/state_generation/src/templates/amalgamation.rs +++ b/examples/core/state_generation/src/templates/amalgamation.rs @@ -2,57 +2,57 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] struct PageState { message: String, } -#[perseus::template] -fn amalgamation_page<'a, G: Html>(cx: Scope<'a>, state: PageStateRx<'a>) -> View<G> { +fn amalgamation_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View<G> { view! { cx, p { (format!("The message is: '{}'", state.message.get())) } } } pub fn get_template<G: Html>() -> Template<G> { - Template::new("amalgamation") + Template::build("amalgamation") // We'll generate some state at build time and some more at request time .build_state_fn(get_build_state) .request_state_fn(get_request_state) // But Perseus doesn't know which one to use, so we provide a function to unify them .amalgamate_states_fn(amalgamate_states) - .template_with_state(amalgamation_page) + .view_with_state(amalgamation_page) + .build() } +// Could be fallible with a `BlamedError` #[engine_only_fn] async fn amalgamate_states( // This takes the same information as build state, request state, etc. _info: StateGeneratorInfo<()>, build_state: PageState, req_state: PageState, -) -> RenderFnResultWithCause<PageState> { - Ok(PageState { +) -> PageState { + PageState { message: format!( "Hello from the amalgamation! (Build says: '{}', server says: '{}'.)", build_state.message, req_state.message ), - }) + } } +// Could be fallible with a `BlamedError` #[engine_only_fn] -async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<PageState> { - Ok(PageState { +async fn get_build_state(_info: StateGeneratorInfo<()>) -> PageState { + PageState { message: "Hello from the build process!".to_string(), - }) + } } +// Could be fallible #[engine_only_fn] -async fn get_request_state( - _info: StateGeneratorInfo<()>, - _req: Request, -) -> RenderFnResultWithCause<PageState> { - Ok(PageState { +async fn get_request_state(_info: StateGeneratorInfo<()>, _req: Request) -> PageState { + PageState { message: "Hello from the server!".to_string(), - }) + } } diff --git a/examples/core/state_generation/src/templates/build_paths.rs b/examples/core/state_generation/src/templates/build_paths.rs index 9e82b1718b..5ffcbfc463 100644 --- a/examples/core/state_generation/src/templates/build_paths.rs +++ b/examples/core/state_generation/src/templates/build_paths.rs @@ -2,46 +2,46 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] struct PageState { title: String, content: String, } -#[perseus::template] -fn build_paths_page<'a, G: Html>(cx: Scope<'a>, state: PageStateRx<'a>) -> View<G> { - let title = state.title; - let content = state.content; +fn build_paths_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View<G> { view! { cx, h1 { - (title.get()) + (format!("build_paths/{}", state.title.get())) } p { - (content.get()) + (state.content.get()) } } } pub fn get_template<G: Html>() -> Template<G> { - Template::new("build_paths") + Template::build("build_paths") .build_paths_fn(get_build_paths) .build_state_fn(get_build_state) - .template_with_state(build_paths_page) + .view_with_state(build_paths_page) + .build() } // We take in `StateGeneratorInfo`, which has the path we're generating for // (*not* including the template name), along with the locale, and some // arbitrary helper state (which we're not using, hence the `()`) +// +// This could be fallible with a `BlamedError` #[engine_only_fn] -async fn get_build_state(info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<PageState> { +async fn get_build_state(info: StateGeneratorInfo<()>) -> PageState { let title = info.path.clone(); let content = format!( "This is a post entitled 'build_paths/{}'. Its original slug was 'build_paths/{}'.", &title, &info.path ); - Ok(PageState { title, content }) + PageState { title, content } } // This just returns a special `struct` containing all the paths we want to @@ -53,8 +53,13 @@ async fn get_build_state(info: StateGeneratorInfo<()>) -> RenderFnResultWithCaus // Note also that there's almost no point in using build paths without build // state, as every page would come out exactly the same (unless you // differentiated them on the client...) +// +// This could return `BuildPaths` directly; this example just shows that it +// could also return an error (which is *not* blamed, since this function, which +// generates paths at build-time, is only going to be run at...well, build-time, +// so the client can't be responsible for any errors we might encounter) #[engine_only_fn] -async fn get_build_paths() -> RenderFnResult<BuildPaths> { +async fn get_build_paths() -> Result<BuildPaths, std::convert::Infallible> { Ok(BuildPaths { // These are the paths we want to generate for, with an empty string being at the root of // the template name (here, `/build_paths`) diff --git a/examples/core/state_generation/src/templates/build_state.rs b/examples/core/state_generation/src/templates/build_state.rs index d33019651e..58803499fa 100644 --- a/examples/core/state_generation/src/templates/build_state.rs +++ b/examples/core/state_generation/src/templates/build_state.rs @@ -2,23 +2,23 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] struct PageState { greeting: String, } -#[perseus::template] -fn build_state_page<'a, G: Html>(cx: Scope<'a>, state: PageStateRx<'a>) -> View<G> { +fn build_state_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View<G> { view! { cx, p { (state.greeting.get()) } } } pub fn get_template<G: Html>() -> Template<G> { - Template::new("build_state") + Template::build("build_state") .build_state_fn(get_build_state) - .template_with_state(build_state_page) + .view_with_state(build_state_page) + .build() } // We're told the path we're generating for (useless unless we're using build @@ -27,8 +27,14 @@ pub fn get_template<G: Html>() -> Template<G> { // a server or the like here (see the `demo/fetching` example), along with any // helper state we generated with build paths (which we aren't using, hence the // `()`) +// +// This returns a `Result` with a `BlamedError`, because, if we were using +// *incremental generation*, then build state might be executed again in future +// (see `incremental_generation.rs` for an example of that). #[engine_only_fn] -async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<PageState> { +async fn get_build_state( + _info: StateGeneratorInfo<()>, +) -> Result<PageState, BlamedError<std::io::Error>> { Ok(PageState { greeting: "Hello World!".to_string(), }) diff --git a/examples/core/state_generation/src/templates/incremental_generation.rs b/examples/core/state_generation/src/templates/incremental_generation.rs index 2f1922d76c..8b0d043aa1 100644 --- a/examples/core/state_generation/src/templates/incremental_generation.rs +++ b/examples/core/state_generation/src/templates/incremental_generation.rs @@ -5,29 +5,29 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] struct PageState { title: String, content: String, } -#[perseus::template] -fn incremental_generation_page<'a, G: Html>(cx: Scope<'a>, state: PageStateRx<'a>) -> View<G> { - let title = state.title; - let content = state.content; +fn incremental_generation_page<'a, G: Html>( + cx: BoundedScope<'_, 'a>, + state: &'a PageStateRx, +) -> View<G> { view! { cx, h1 { - (title.get()) + (state.title.get()) } p { - (content.get()) + (state.content.get()) } } } pub fn get_template<G: Html>() -> Template<G> { - Template::new("incremental_generation") + Template::build("incremental_generation") .build_paths_fn(get_build_paths) .build_state_fn(get_build_state) // This line makes Perseus try to render any given path under the template's root path @@ -35,24 +35,36 @@ pub fn get_template<G: Html>() -> Template<G> { // filter the path because some are invalid (e.g. entries that aren't in some database), we // can filter them out at the state of the build state function .incremental_generation() - .template_with_state(incremental_generation_page) + .view_with_state(incremental_generation_page) + .build() } // This will be executed at build-time for all the paths in `get_build_paths()`, // and then again for any other paths that a user might request while the app is -// live +// live, meaning any errors could come from either the server or the client, +// hence why this returns a `BlamedError`. We use a `std::io::Error` because we +// need soemthing that implements `std::error::Error`, but you could use +// anything here. #[engine_only_fn] async fn get_build_state( StateGeneratorInfo { path, .. }: StateGeneratorInfo<()>, -) -> RenderFnResultWithCause<PageState> { +) -> Result<PageState, BlamedError<std::io::Error>> { // This path is illegal, and can't be rendered // Because we're using incremental generation, we could get literally anything // as the `path` - if path == "incremental_generation/tests" { + if path == "tests" { // This tells Perseus to return an error that's the client's fault, with the - // HTTP status code 404 (not found) and the message 'illegal page' - // You could return this error manually, but this is more convenient - blame_err!(client, 404, "illegal page"); + // HTTP status code 404 (not found) and the message 'illegal page'. Note that + // this is a `BlamedError<String>`, but we could use any error type that + // implements `std::error::Error` (note that this does make `anyhow` a + // bit tricky, if you use it). + return Err(BlamedError { + // If we used `None` instead, it would default to 400 for the client and 500 for the + // server + blame: ErrorBlame::Client(Some(404)), + // This is just an example, and you could put any error type here, usually your own + error: std::io::Error::new(std::io::ErrorKind::NotFound, "illegal page"), + }); } let title = path.clone(); let content = format!( @@ -65,9 +77,9 @@ async fn get_build_state( // See `../build_paths.rs` for an explanation of this #[engine_only_fn] -async fn get_build_paths() -> RenderFnResult<BuildPaths> { - Ok(BuildPaths { +async fn get_build_paths() -> BuildPaths { + BuildPaths { paths: vec!["test".to_string(), "blah/test/blah".to_string()], extra: ().into(), - }) + } } diff --git a/examples/core/state_generation/src/templates/request_state.rs b/examples/core/state_generation/src/templates/request_state.rs index 5f4a551577..8f22451d53 100644 --- a/examples/core/state_generation/src/templates/request_state.rs +++ b/examples/core/state_generation/src/templates/request_state.rs @@ -2,14 +2,13 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] struct PageState { ip: String, } -#[perseus::template] -fn request_state_page<'a, G: Html>(cx: Scope<'a>, state: PageStateRx<'a>) -> View<G> { +fn request_state_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View<G> { view! { cx, p { ( @@ -20,11 +19,18 @@ fn request_state_page<'a, G: Html>(cx: Scope<'a>, state: PageStateRx<'a>) -> Vie } pub fn get_template<G: Html>() -> Template<G> { - Template::new("request_state") + Template::build("request_state") .request_state_fn(get_request_state) - .template_with_state(request_state_page) + .view_with_state(request_state_page) + .build() } +// This returns a `Result<T, BlamedError<E>>` (or just `T`) because, obviously, +// it will be run at request-time: any errors could be a mising file (our +// fault), or a malformed cookie (the client's fault), etc., so we have to note +// the blame to get an accurate HTTP status code. This example is really +// infallible, but we've spelled it all out rather than using `T` so you can see +// how it works. #[engine_only_fn] async fn get_request_state( // We get all the same info as build state in here @@ -33,7 +39,7 @@ async fn get_request_state( // user sent with their HTTP request IN this example, we extract the browser's reporting of // their IP address and display it to them req: Request, -) -> RenderFnResultWithCause<PageState> { +) -> Result<PageState, BlamedError<std::convert::Infallible>> { Ok(PageState { ip: format!( "{:?}", diff --git a/examples/core/state_generation/src/templates/revalidation.rs b/examples/core/state_generation/src/templates/revalidation.rs index fd3c6c9cb7..5d2f9356f3 100644 --- a/examples/core/state_generation/src/templates/revalidation.rs +++ b/examples/core/state_generation/src/templates/revalidation.rs @@ -2,22 +2,21 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] struct PageState { time: String, } -#[perseus::template] -fn revalidation_page<'a, G: Html>(cx: Scope<'a>, state: PageStateRx<'a>) -> View<G> { +fn revalidation_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View<G> { view! { cx, p { (format!("The time when this page was last rendered was '{}'.", state.time.get())) } } } pub fn get_template<G: Html>() -> Template<G> { - Template::new("revalidation") - .template_with_state(revalidation_page) + Template::build("revalidation") + .view_with_state(revalidation_page) // This page will revalidate every five seconds (and so the time displayed will be updated) .revalidate_after("5s") // This is an alternative method of revalidation that uses logic, which will be executed @@ -27,25 +26,28 @@ pub fn get_template<G: Html>() -> Template<G> { // tells Perseus that it should revalidate. .should_revalidate_fn(should_revalidate) .build_state_fn(get_build_state) + .build() } // This will get the system time when the app was built #[engine_only_fn] -async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<PageState> { - Ok(PageState { +async fn get_build_state(_info: StateGeneratorInfo<()>) -> PageState { + PageState { time: format!("{:?}", std::time::SystemTime::now()), - }) + } } // This will run every time `.revalidate_after()` permits the page to be // revalidated This acts as a secondary check, and can perform arbitrary logic -// to check if we should actually revalidate a page +// to check if we should actually revalidate a page. +// +// Since this takes the request, this uses a `BlamedError` if it's fallible. #[engine_only_fn] async fn should_revalidate( // This takes the same arguments as request state _info: StateGeneratorInfo<()>, _req: perseus::Request, -) -> RenderFnResultWithCause<bool> { +) -> Result<bool, BlamedError<std::convert::Infallible>> { // For simplicity's sake, this will always say we should revalidate, but you // could make this check any condition Ok(true) diff --git a/examples/core/state_generation/src/templates/revalidation_and_incremental_generation.rs b/examples/core/state_generation/src/templates/revalidation_and_incremental_generation.rs index 8b5386bea5..ed12ce88a8 100644 --- a/examples/core/state_generation/src/templates/revalidation_and_incremental_generation.rs +++ b/examples/core/state_generation/src/templates/revalidation_and_incremental_generation.rs @@ -6,16 +6,15 @@ use serde::{Deserialize, Serialize}; use std::time::Duration; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] struct PageState { time: String, } -#[perseus::template] fn revalidation_and_incremental_generation_page<'a, G: Html>( - cx: Scope<'a>, - state: PageStateRx<'a>, + cx: BoundedScope<'_, 'a>, + state: &'a PageStateRx, ) -> View<G> { view! { cx, p { (format!("The time when this page was last rendered was '{}'.", state.time.get())) } @@ -23,8 +22,8 @@ fn revalidation_and_incremental_generation_page<'a, G: Html>( } pub fn get_template<G: Html>() -> Template<G> { - Template::new("revalidation_and_incremental_generation") - .template_with_state(revalidation_and_incremental_generation_page) + Template::build("revalidation_and_incremental_generation") + .view_with_state(revalidation_and_incremental_generation_page) // This page will revalidate every five seconds (and so the time displayed will be updated) .revalidate_after(Duration::new(5, 0)) // This is an alternative method of revalidation that uses logic, which will be executed @@ -38,33 +37,31 @@ pub fn get_template<G: Html>() -> Template<G> { // WARNING: this will revalidate on every reload in development, because incremental // generation is recalculated on every request in development .incremental_generation() + .build() } // This will get the system time when the app was built #[engine_only_fn] -async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<PageState> { - Ok(PageState { +async fn get_build_state(_info: StateGeneratorInfo<()>) -> PageState { + PageState { time: format!("{:?}", std::time::SystemTime::now()), - }) + } } #[engine_only_fn] -async fn get_build_paths() -> RenderFnResult<BuildPaths> { - Ok(BuildPaths { +async fn get_build_paths() -> BuildPaths { + BuildPaths { paths: vec!["test".to_string(), "blah/test/blah".to_string()], extra: ().into(), - }) + } } // This will run every time `.revalidate_after()` permits the page to be // revalidated This acts as a secondary check, and can perform arbitrary logic // to check if we should actually revalidate a page #[engine_only_fn] -async fn should_revalidate( - _info: StateGeneratorInfo<()>, - _req: perseus::Request, -) -> RenderFnResultWithCause<bool> { +async fn should_revalidate(_info: StateGeneratorInfo<()>, _req: perseus::Request) -> bool { // For simplicity's sake, this will always say we should revalidate, but you // could make this check any condition - Ok(true) + true } diff --git a/examples/core/state_generation/tests/main.rs b/examples/core/state_generation/tests/main.rs index 0b61f2c68a..157f220532 100644 --- a/examples/core/state_generation/tests/main.rs +++ b/examples/core/state_generation/tests/main.rs @@ -4,7 +4,7 @@ use perseus::wait_for_checkpoint; #[perseus::test] async fn build_state(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { c.goto("http://localhost:8080/build_state").await?; - wait_for_checkpoint!("initial_state_present", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); // This greeting is passed in as a build state prop let text = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(text, "Hello World!"); @@ -20,7 +20,7 @@ async fn build_paths(c: &mut Client) -> Result<(), fantoccini::error::CmdError> ) -> Result<(), fantoccini::error::CmdError> { c.goto(&format!("http://localhost:8080/build_paths{}", page)) .await?; - wait_for_checkpoint!("initial_state_present", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); // There should be a heading with the slug let text = c.find(Locator::Css("h1")).await?.text().await?; assert!(text.contains(&format!("build_paths{}", page))); @@ -52,10 +52,10 @@ async fn incremental_generation(c: &mut Client) -> Result<(), fantoccini::error: page )) .await?; - wait_for_checkpoint!("initial_state_present", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); // There should be a heading with the slug let text = c.find(Locator::Css("h1")).await?.text().await?; - assert!(text.contains(page)); + assert!(text.contains(page.strip_prefix("/").unwrap_or(&page))); Ok(()) } @@ -68,7 +68,12 @@ async fn incremental_generation(c: &mut Client) -> Result<(), fantoccini::error: // Finally, test an illegal URL c.goto("http://localhost:8080/incremental_generation/tests") .await?; - wait_for_checkpoint!("initial_state_error", 0, c); + // This is actually very important: incremental pages that are invalidated on + // the engine-side will appear valid to the browser, leading to a + // `FullRouteVerdict::Found` variant. It is imperative that the `not_found` + // checkpoint is executed somehow even in this case, otherwise + // users are likely to find themselves with almost undiagnosable errors. + wait_for_checkpoint!("not_found", 0, c); // There should be an error page let text = c.find(Locator::Css("p")).await?.text().await?; assert!(text.contains("not found")); @@ -80,7 +85,7 @@ async fn incremental_generation(c: &mut Client) -> Result<(), fantoccini::error: #[perseus::test] async fn amalgamation(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { c.goto("http://localhost:8080/amalgamation").await?; - wait_for_checkpoint!("initial_state_present", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); // This page naively combines build and request states into a single message let text = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(text, "The message is: 'Hello from the amalgamation! (Build says: 'Hello from the build process!', server says: 'Hello from the server!'.)'"); @@ -92,7 +97,7 @@ async fn amalgamation(c: &mut Client) -> Result<(), fantoccini::error::CmdError> #[perseus::test] async fn request_state(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { c.goto("http://localhost:8080/request_state").await?; - wait_for_checkpoint!("initial_state_present", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); let text = c.find(Locator::Css("p")).await?.text().await?; // Unfortunately, we can't easily make the headless browser set the necessary // headers to allow Perseus to actually get the IP address @@ -105,7 +110,7 @@ async fn request_state(c: &mut Client) -> Result<(), fantoccini::error::CmdError #[perseus::test] async fn revalidation(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { c.goto("http://localhost:8080/revalidation").await?; - wait_for_checkpoint!("initial_state_present", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); let text = c.find(Locator::Css("p")).await?.text().await?; // We'll wait for five seconds, then reload the page and expect the content to // be different @@ -129,7 +134,7 @@ async fn revalidation_and_incremental_generation( page )) .await?; - wait_for_checkpoint!("initial_state_present", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); let text = c.find(Locator::Css("p")).await?.text().await?; // We'll wait for five seconds, then reload the page and expect the content to // be different diff --git a/examples/core/static_content/src/error_pages.rs b/examples/core/static_content/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/static_content/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/static_content/src/main.rs b/examples/core/static_content/src/main.rs index c83b9bbebf..0405dde9ee 100644 --- a/examples/core/static_content/src/main.rs +++ b/examples/core/static_content/src/main.rs @@ -1,12 +1,11 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) .static_alias("/test.txt", "test.txt") } diff --git a/examples/core/static_content/src/templates/index.rs b/examples/core/static_content/src/templates/index.rs index 6adc4a70d9..c50a870474 100644 --- a/examples/core/static_content/src/templates/index.rs +++ b/examples/core/static_content/src/templates/index.rs @@ -15,5 +15,5 @@ fn head(cx: Scope) -> View<SsrNode> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index").template(index_page).head(head) + Template::build("index").view(index_page).head(head).build() } diff --git a/examples/core/suspense/src/error_pages.rs b/examples/core/suspense/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/suspense/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/suspense/src/main.rs b/examples/core/suspense/src/main.rs index 87ed16df4a..36895e1bb6 100644 --- a/examples/core/suspense/src/main.rs +++ b/examples/core/suspense/src/main.rs @@ -1,11 +1,10 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) } diff --git a/examples/core/suspense/src/templates/index.rs b/examples/core/suspense/src/templates/index.rs index 4b475b54e6..a66765ef19 100644 --- a/examples/core/suspense/src/templates/index.rs +++ b/examples/core/suspense/src/templates/index.rs @@ -7,7 +7,7 @@ use gloo_timers::future::sleep; #[cfg(target_arch = "wasm32")] use std::time::Duration; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "IndexPageStateRx")] struct IndexPageState { #[rx(suspense = "greeting_handler")] @@ -35,14 +35,13 @@ struct Test { // *will not execute*! second_greeting: String, } -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] struct OtherTest { #[rx(suspense = "other_test_handler")] third_greeting: Result<String, SerdeInfallible>, } -#[template] -fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPageStateRx<'a>) -> View<G> { +fn index_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a IndexPageStateRx) -> View<G> { let greeting = create_memo(cx, || match &*state.greeting.get() { Ok(state) => state.to_string(), Err(_) => unreachable!(), @@ -65,6 +64,10 @@ fn index_page<'a, G: Html>(cx: Scope<'a>, state: IndexPageStateRx<'a>) -> View<G } } +// Unfortunately, you can't just return `T` from suspense handlers as you can +// with state generation functions like `get_build_state`, due to constraints +// within the Rust language. Hopefully, this will be one day possible! + // This takes the same reactive scope as `index_page`, along with the individual // reactive version of the `greeting` field (notice the parallels to // `index_page`'s signature). This doesn't return any value, it uses Sycamore's @@ -91,7 +94,7 @@ async fn greeting_handler<'a>( #[browser_only_fn] async fn test_handler<'a>( _cx: Scope<'a>, - test: RxResultRef<'a, Test, String>, + test: &'a RxResultRx<Test, String>, ) -> Result<(), String> { sleep(Duration::from_secs(1)).await; // Unfortunately, this verbosity is necessary until `Try` is stabilized so we @@ -117,8 +120,8 @@ async fn other_test_handler<'a>( } #[engine_only_fn] -async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<IndexPageState> { - Ok(IndexPageState { +async fn get_build_state(_info: StateGeneratorInfo<()>) -> IndexPageState { + IndexPageState { greeting: Ok("Hello from the server!".to_string()), // `RxResult` can be created from a standard `Result` with a simple `.into()` test: Ok(Test { @@ -128,12 +131,13 @@ async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCau other_test: OtherTest { third_greeting: Ok("Hello again again from the server!".to_string()), }, - }) + } } pub fn get_template<G: Html>() -> Template<G> { // Note that suspense handlers are registered through the state, not here - Template::new("index") - .template_with_state(index_page) + Template::build("index") + .view_with_state(index_page) .build_state_fn(get_build_state) + .build() } diff --git a/examples/core/suspense/tests/main.rs b/examples/core/suspense/tests/main.rs index 88dba0838f..feae7295e4 100644 --- a/examples/core/suspense/tests/main.rs +++ b/examples/core/suspense/tests/main.rs @@ -10,8 +10,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); - wait_for_checkpoint!("initial_state_present", 0, c); - // Get each of the greetings let mut first = c.find(Locator::Id("first")).await?; let mut second = c.find(Locator::Id("second")).await?; diff --git a/examples/core/unreactive/src/error_pages.rs b/examples/core/unreactive/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/core/unreactive/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/core/unreactive/src/main.rs b/examples/core/unreactive/src/main.rs index 02fdf9edfb..c33ac0cedb 100644 --- a/examples/core/unreactive/src/main.rs +++ b/examples/core/unreactive/src/main.rs @@ -1,12 +1,11 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) .template(crate::templates::about::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) } diff --git a/examples/core/unreactive/src/templates/about.rs b/examples/core/unreactive/src/templates/about.rs index d7712a9d09..539de82558 100644 --- a/examples/core/unreactive/src/templates/about.rs +++ b/examples/core/unreactive/src/templates/about.rs @@ -8,7 +8,7 @@ fn about_page<G: Html>(cx: Scope) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("about").template(about_page).head(head) + Template::build("about").view(about_page).head(head).build() } #[engine_only_fn] diff --git a/examples/core/unreactive/src/templates/index.rs b/examples/core/unreactive/src/templates/index.rs index 7dee3af094..50561eedf6 100644 --- a/examples/core/unreactive/src/templates/index.rs +++ b/examples/core/unreactive/src/templates/index.rs @@ -25,10 +25,11 @@ fn index_page<G: Html>(cx: Scope, state: IndexPageState) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index") + Template::build("index") .build_state_fn(get_build_state) - .template_with_unreactive_state(index_page) + .view_with_unreactive_state(index_page) .head_with_state(head) + .build() } #[engine_only_fn] @@ -39,8 +40,8 @@ fn head(cx: Scope, _props: IndexPageState) -> View<SsrNode> { } #[engine_only_fn] -async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<IndexPageState> { - Ok(IndexPageState { +async fn get_build_state(_info: StateGeneratorInfo<()>) -> IndexPageState { + IndexPageState { greeting: "Hello World!".to_string(), - }) + } } diff --git a/examples/core/unreactive/tests/main.rs b/examples/core/unreactive/tests/main.rs index 7b2d67894f..fb0a5061be 100644 --- a/examples/core/unreactive/tests/main.rs +++ b/examples/core/unreactive/tests/main.rs @@ -9,7 +9,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { assert!(url.as_ref().starts_with("http://localhost:8080")); // The greeting was passed through using build state - wait_for_checkpoint!("initial_state_present", 0, c); wait_for_checkpoint!("page_interactive", 0, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Hello World!"); diff --git a/examples/demos/auth/src/error_pages.rs b/examples/demos/auth/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/demos/auth/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/demos/auth/src/global_state.rs b/examples/demos/auth/src/global_state.rs index 08f2490e2a..5797af9847 100644 --- a/examples/demos/auth/src/global_state.rs +++ b/examples/demos/auth/src/global_state.rs @@ -6,14 +6,14 @@ pub fn get_global_state_creator() -> GlobalStateCreator { } #[engine_only_fn] -async fn get_build_state(_locale: String) -> RenderFnResult<AppState> { - Ok(AppState { +async fn get_build_state(_locale: String) -> AppState { + AppState { // We explicitly tell the first page that no login state has been checked yet auth: AuthData { state: LoginState::Server, username: String::new(), }, - }) + } } #[derive(Serialize, Deserialize, ReactiveState)] @@ -50,13 +50,16 @@ pub struct AuthData { // here (hence the `.get()`s and `.set()`s, all the fields become `Signal`s) // There's no point in implementing it on the unreactive version, since this // will only be called from within the browser, in which we have a reactive -// version +// version. +// +// Unfortunately, Rust doesn't let us use the reference alias for this, so +// we add `PerseusRxRef`, which is the internal name. #[cfg(target_arch = "wasm32")] // These functions all use `web_sys`, and so won't work on the server-side -impl<'a> AuthDataRx<'a> { +impl<'a> AuthDataPerseusRxRef<'a> { /// Checks whether or not the user is logged in and modifies the internal /// state accordingly. If this has already been run, it won't do anything /// (aka. it will only run if it's `Server`) - pub fn detect_state(&self) { + pub fn detect_state(&'a self) { // If we've checked the login status before, then we should assume the status // hasn't changed (we'd change this in a login/logout page) if let LoginState::Yes | LoginState::No = *self.state.get() { @@ -84,14 +87,14 @@ impl<'a> AuthDataRx<'a> { } /// Logs the user in with the given username. - pub fn login(&self, username: &str) { + pub fn login(&'a self, username: &str) { let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); storage.set("username", username).unwrap(); self.state.set(LoginState::Yes); self.username.set(username.to_string()); } /// Logs the user out. - pub fn logout(&self) { + pub fn logout(&'a self) { let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); storage.delete("username").unwrap(); self.state.set(LoginState::No); diff --git a/examples/demos/auth/src/main.rs b/examples/demos/auth/src/main.rs index f4b5c158f5..b710ecc221 100644 --- a/examples/demos/auth/src/main.rs +++ b/examples/demos/auth/src/main.rs @@ -1,14 +1,13 @@ -mod error_pages; mod global_state; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) .template(crate::templates::about::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) .global_state_creator(crate::global_state::get_global_state_creator()) } diff --git a/examples/demos/auth/src/templates/about.rs b/examples/demos/auth/src/templates/about.rs index ddb7d48b4d..9c95a4f02d 100644 --- a/examples/demos/auth/src/templates/about.rs +++ b/examples/demos/auth/src/templates/about.rs @@ -1,5 +1,5 @@ -use perseus::Template; -use sycamore::prelude::{view, Html, Scope, View}; +use perseus::prelude::*; +use sycamore::prelude::*; fn about_page<G: Html>(cx: Scope) -> View<G> { view! { cx, @@ -9,5 +9,5 @@ fn about_page<G: Html>(cx: Scope) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("about").template(about_page) + Template::build("about").view(about_page).build() } diff --git a/examples/demos/auth/src/templates/index.rs b/examples/demos/auth/src/templates/index.rs index 3a19ea736e..fe261f1d82 100644 --- a/examples/demos/auth/src/templates/index.rs +++ b/examples/demos/auth/src/templates/index.rs @@ -4,8 +4,7 @@ use sycamore::prelude::*; use crate::global_state::*; fn index_view<G: Html>(cx: Scope) -> View<G> { - let AppStateRx { auth } = RenderCtx::from_ctx(cx).get_global_state::<AppStateRx>(cx); - + let AppStateRx { auth } = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx); let AuthDataRx { state, username } = auth; // This isn't part of our data model because it's only used here to pass to the // login function @@ -51,5 +50,5 @@ fn index_view<G: Html>(cx: Scope) -> View<G> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index").template(index_view) + Template::build("index").view(index_view).build() } diff --git a/examples/demos/fetching/src/error_pages.rs b/examples/demos/fetching/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/demos/fetching/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/demos/fetching/src/main.rs b/examples/demos/fetching/src/main.rs index 87ed16df4a..36895e1bb6 100644 --- a/examples/demos/fetching/src/main.rs +++ b/examples/demos/fetching/src/main.rs @@ -1,11 +1,10 @@ -mod error_pages; mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) } diff --git a/examples/demos/fetching/src/templates/index.rs b/examples/demos/fetching/src/templates/index.rs index 4cb63cafa7..2bbfa517ee 100644 --- a/examples/demos/fetching/src/templates/index.rs +++ b/examples/demos/fetching/src/templates/index.rs @@ -2,20 +2,19 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[derive(Serialize, Deserialize, ReactiveState)] +#[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "IndexPageStateRx")] struct IndexPageState { server_ip: String, browser_ip: Option<String>, } -#[perseus::template] fn index_page<'a, G: Html>( - cx: Scope<'a>, + cx: BoundedScope<'_, 'a>, IndexPageStateRx { server_ip, browser_ip, - }: IndexPageStateRx<'a>, + }: &'a IndexPageStateRx, ) -> View<G> { // This will only run in the browser // `reqwasm` wraps browser-specific APIs, so we don't want it running on the @@ -62,13 +61,16 @@ fn index_page<'a, G: Html>( } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index") + Template::build("index") .build_state_fn(get_build_state) - .template_with_state(index_page) + .view_with_state(index_page) + .build() } #[engine_only_fn] -async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCause<IndexPageState> { +async fn get_build_state( + _info: StateGeneratorInfo<()>, +) -> Result<IndexPageState, BlamedError<reqwest::Error>> { // We'll cache the result with `try_cache_res`, which means we only make the // request once, and future builds will use the cached result (speeds up // development) @@ -81,7 +83,8 @@ async fn get_build_state(_info: StateGeneratorInfo<()>) -> RenderFnResultWithCau }, false, ) - .await?; + .await?; // Note that `?` is able to convert from `reqwest::Error` -> + // `BlamedError<reqwest::Error>` Ok(IndexPageState { server_ip: body, diff --git a/examples/demos/full_page_layout/src/error_pages.rs b/examples/demos/full_page_layout/src/error_pages.rs deleted file mode 100644 index 2ad326416c..0000000000 --- a/examples/demos/full_page_layout/src/error_pages.rs +++ /dev/null @@ -1,32 +0,0 @@ -use perseus::{ErrorPages, Html}; -use sycamore::view; - -pub fn get_error_pages<G: Html>() -> ErrorPages<G> { - let mut error_pages = ErrorPages::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages -} diff --git a/examples/demos/full_page_layout/src/main.rs b/examples/demos/full_page_layout/src/main.rs index 894587b724..39655febf2 100644 --- a/examples/demos/full_page_layout/src/main.rs +++ b/examples/demos/full_page_layout/src/main.rs @@ -1,8 +1,7 @@ mod components; -mod error_pages; mod templates; -use perseus::{Html, PerseusApp, PerseusRoot}; +use perseus::prelude::*; use sycamore::prelude::view; #[perseus::main(perseus_warp::dflt_server)] @@ -10,7 +9,7 @@ pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) .template(crate::templates::long::get_template()) - .error_pages(crate::error_pages::get_error_pages()) + .error_views(ErrorViews::unlocalized_development_default()) .index_view(|cx| { view! { cx, html { diff --git a/examples/demos/full_page_layout/src/templates/index.rs b/examples/demos/full_page_layout/src/templates/index.rs index 4adbb79123..b838e9356e 100644 --- a/examples/demos/full_page_layout/src/templates/index.rs +++ b/examples/demos/full_page_layout/src/templates/index.rs @@ -21,5 +21,5 @@ fn head(cx: Scope) -> View<SsrNode> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index").template(index_page).head(head) + Template::build("index").view(index_page).head(head).build() } diff --git a/examples/demos/full_page_layout/src/templates/long.rs b/examples/demos/full_page_layout/src/templates/long.rs index a67678d044..12216bd7c0 100644 --- a/examples/demos/full_page_layout/src/templates/long.rs +++ b/examples/demos/full_page_layout/src/templates/long.rs @@ -23,5 +23,5 @@ fn head(cx: Scope) -> View<SsrNode> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("long").template(long_page).head(head) + Template::build("long").view(long_page).head(head).build() } diff --git a/examples/website/app_in_a_file/src/main.rs b/examples/website/app_in_a_file/src/main.rs index b24849c7b7..0a80cc731f 100644 --- a/examples/website/app_in_a_file/src/main.rs +++ b/examples/website/app_in_a_file/src/main.rs @@ -9,7 +9,7 @@ pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() // Create a new template at `index`, which maps to our landing page .template( - Template::new("index") + Template::build("index") .template_with_state(index_page) .build_state_fn(get_index_build_state), ) diff --git a/examples/website/state_generation/src/main.rs b/examples/website/state_generation/src/main.rs index f8abca50bf..7dbd5b1538 100644 --- a/examples/website/state_generation/src/main.rs +++ b/examples/website/state_generation/src/main.rs @@ -6,7 +6,7 @@ use sycamore::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new().template( - Template::new("post") + Template::build("post") .template_with_state(post_page) .build_paths_fn(get_build_paths) .build_state_fn(get_build_state) diff --git a/packages/perseus-actix-web/Cargo.toml b/packages/perseus-actix-web/Cargo.toml index c377f0600d..35579a3ebb 100644 --- a/packages/perseus-actix-web/Cargo.toml +++ b/packages/perseus-actix-web/Cargo.toml @@ -15,17 +15,9 @@ categories = ["wasm", "web-programming::http-server", "development-tools", "asyn [dependencies] perseus = { path = "../perseus", version = "0.4.0-beta.11" } -actix-web = "=4.1.0" -actix-http = "=3.2.1" # Without this, Actix can introduce breaking changes in a dependency tree -# actix-router = "=0.5.0-rc.3" -actix-files = "=0.6.2" -urlencoding = "2.1" -serde = "1" -serde_json = "1" -thiserror = "1" -fmterr = "0.1" +actix-web = "4.2" +actix-files = "0.6" futures = "0.3" -sycamore = { version = "^0.8.1", features = ["ssr"] } [features] # Enables the default server configuration, which provides a convenience function if you're not adding any extra routes diff --git a/packages/perseus-actix-web/src/configurer.rs b/packages/perseus-actix-web/src/configurer.rs deleted file mode 100644 index 0fa02d0a38..0000000000 --- a/packages/perseus-actix-web/src/configurer.rs +++ /dev/null @@ -1,108 +0,0 @@ -use crate::initial_load::initial_load; -use crate::page_data::page_data; -use crate::translations::translations; -use actix_files::{Files, NamedFile}; -use actix_web::{web, HttpRequest}; -use perseus::{ - i18n::TranslationsManager, - server::{get_render_cfg, ServerOptions, ServerProps}, - state::get_built_global_state, - stores::MutableStore, -}; - -async fn js_bundle(opts: web::Data<ServerOptions>) -> std::io::Result<NamedFile> { - NamedFile::open(&opts.js_bundle) -} -async fn wasm_bundle(opts: web::Data<ServerOptions>) -> std::io::Result<NamedFile> { - NamedFile::open(&opts.wasm_bundle) -} -async fn wasm_js_bundle(opts: web::Data<ServerOptions>) -> std::io::Result<NamedFile> { - NamedFile::open(&opts.wasm_js_bundle) -} -async fn static_alias( - opts: web::Data<ServerOptions>, - req: HttpRequest, -) -> std::io::Result<NamedFile> { - let filename = opts.static_aliases.get(req.path()); - let filename = match filename { - Some(filename) => filename, - // If the path doesn't exist, then the alias is not found - None => return Err(std::io::Error::from(std::io::ErrorKind::NotFound)), - }; - NamedFile::open(filename) -} - -/// 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<M: MutableStore + 'static, T: TranslationsManager + 'static>( - ServerProps { - opts, - immutable_store, - mutable_store, - translations_manager, - global_state_creator, - }: ServerProps<M, T>, -) -> impl FnOnce(&mut actix_web::web::ServiceConfig) { - let render_cfg = get_render_cfg(&immutable_store) - .await - .expect("Couldn't get render configuration!"); - let index_with_render_cfg = opts.html_shell.clone(); - // Generate the global state - let global_state = get_built_global_state(&immutable_store) - .await - .expect("couldn't get pre-built global state or placeholder (the app's build artifacts have almost certainly been corrupted)"); - - move |cfg: &mut web::ServiceConfig| { - cfg - // We implant the render config in the app data for better performance, it's needed on - // every request - .app_data(web::Data::new(render_cfg.clone())) - .app_data(web::Data::new(immutable_store.clone())) - .app_data(web::Data::new(mutable_store.clone())) - .app_data(web::Data::new(translations_manager.clone())) - .app_data(web::Data::new(opts.clone())) - .app_data(web::Data::new(index_with_render_cfg.clone())) - .app_data(web::Data::new(global_state.clone())) - .app_data(web::Data::new(global_state_creator.clone())) - // TODO chunk JS and Wasm bundles - // These allow getting the basic app code (not including the static data) - // This contains everything in the spirit of a pseudo-SPA - .route("/.perseus/bundle.js", web::get().to(js_bundle)) - .route("/.perseus/bundle.wasm", web::get().to(wasm_bundle)) - .route("/.perseus/bundle.wasm.js", web::get().to(wasm_js_bundle)) - // This allows getting the static HTML/JSON of a page - // We stream both together in a single JSON object so SSR works (otherwise we'd have - // request IDs and weird caching...) A request to this should also provide - // the template name (routing should only be done once on the client) as a query - // parameter - .route( - "/.perseus/page/{locale}/{filename:.*}.json", - web::get().to(page_data::<M, T>), - ) - // This allows the app shell to fetch translations for a given page - .route( - "/.perseus/translations/{locale}", - web::get().to(translations::<T>), - ) - // This allows getting JS interop snippets (including ones that are supposedly - // 'inlined') These won't change, so they can be set as a filesystem - // dependency safely - .service(Files::new("/.perseus/snippets", &opts.snippets)); - // Now we add support for any static content the user wants to provide - if let Some(static_dir) = &opts.static_dir { - cfg.service(Files::new("/.perseus/static", static_dir)); - } - // And finally add in aliases for static content as necessary - for (url, _static_path) in opts.static_aliases.iter() { - // This handler indexes the path of the request in `opts.static_aliases` to - // figure out what to serve - cfg.route(url, web::get().to(static_alias)); - } - // For everything else, we'll serve the app shell directly - // This has to be done AFTER everything else, because it will match anything - // that's left - cfg.route("{route:.*}", web::get().to(initial_load::<M, T>)); - } -} diff --git a/packages/perseus-actix-web/src/conv_req.rs b/packages/perseus-actix-web/src/conv_req.rs deleted file mode 100644 index 5b1ce53279..0000000000 --- a/packages/perseus-actix-web/src/conv_req.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::errors::*; -use perseus::{HttpRequest, Request}; - -/// Converts an Actix Web request into an `http::request`. -pub fn convert_req(raw: &actix_web::HttpRequest) -> Result<Request, Error> { - let mut builder = HttpRequest::builder(); - - for (name, val) in raw.headers() { - builder = builder.header(name, val); - } - - builder - .uri(raw.uri()) - .method(raw.method()) - .version(raw.version()) - // We always use an empty body because, in a Perseus request, only the URI matters - // Any custom data should therefore be sent in headers (if you're doing that, consider a - // dedicated API) - .body(()) - .map_err(|err| Error::RequestConversionFailed { source: err }) -} diff --git a/packages/perseus-actix-web/src/dflt_server.rs b/packages/perseus-actix-web/src/dflt_server.rs deleted file mode 100644 index 5021761ade..0000000000 --- a/packages/perseus-actix-web/src/dflt_server.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::configurer; -use actix_web::{App, HttpServer}; -use futures::executor::block_on; -use perseus::{i18n::TranslationsManager, server::ServerProps, stores::MutableStore}; - -/// Creates and starts the default Perseus server using Actix Web. This should -/// be run in a `main()` function annotated with `#[tokio::main]` (which -/// requires the `macros` and `rt-multi-thread` features on the `tokio` -/// dependency). -pub async fn dflt_server<M: MutableStore + 'static, T: TranslationsManager + 'static>( - props: ServerProps<M, T>, - (host, port): (String, u16), -) { - // TODO Fix issues here - HttpServer::new(move || - App::new() - .configure( - block_on( - configurer( - props.clone() - ) - ) - ) - ) - .bind((host, port)) - .expect("Couldn't bind to given address. Maybe something is already running on the selected port?") - .run() - .await - .expect("Server failed.") // TODO Improve error message here -} diff --git a/packages/perseus-actix-web/src/errors.rs b/packages/perseus-actix-web/src/errors.rs deleted file mode 100644 index 6f7fcf08f4..0000000000 --- a/packages/perseus-actix-web/src/errors.rs +++ /dev/null @@ -1,11 +0,0 @@ -#![allow(missing_docs)] -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum Error { - #[error("couldn't convert request from actix-web format to perseus format")] - RequestConversionFailed { - #[source] - source: actix_web::error::HttpError, - }, -} diff --git a/packages/perseus-actix-web/src/initial_load.rs b/packages/perseus-actix-web/src/initial_load.rs deleted file mode 100644 index 9db7137335..0000000000 --- a/packages/perseus-actix-web/src/initial_load.rs +++ /dev/null @@ -1,174 +0,0 @@ -use crate::conv_req::convert_req; -use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse}; -use fmterr::fmt_err; -use perseus::{ - errors::{err_to_status_code, ServerError}, - i18n::{TranslationsManager, Translator}, - router::{match_route_atomic, RouteInfoAtomic, RouteVerdictAtomic}, - server::{ - build_error_page, get_page_for_template, get_path_slice, GetPageProps, HtmlShell, - ServerOptions, - }, - state::GlobalStateCreator, - stores::{ImmutableStore, MutableStore}, - template::TemplateState, - utils::get_path_prefix_server, - ErrorPages, SsrNode, -}; -use std::rc::Rc; -use std::{collections::HashMap, sync::Arc}; - -/// Builds on the internal Perseus primitives to provide a utility function that -/// returns an `HttpResponse` 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<Rc<Translator>>, - error_pages: &ErrorPages<SsrNode>, - html_shell: &HtmlShell, -) -> HttpResponse { - let html = build_error_page(url, status, err, translator, error_pages, html_shell); - HttpResponse::build(StatusCode::from_u16(status).unwrap()) - .content_type("text/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. -#[allow(clippy::too_many_arguments)] -pub async fn initial_load<M: MutableStore, T: TranslationsManager>( - req: HttpRequest, - opts: web::Data<ServerOptions>, - html_shell: web::Data<HtmlShell>, - render_cfg: web::Data<HashMap<String, String>>, - immutable_store: web::Data<ImmutableStore>, - mutable_store: web::Data<M>, - translations_manager: web::Data<T>, - global_state: web::Data<TemplateState>, - gsc: web::Data<Arc<GlobalStateCreator>>, -) -> HttpResponse { - let templates = &opts.templates_map; - let error_pages = &opts.error_pages; - let path = req.path(); - let path = match urlencoding::decode(path) { - Ok(path) => path.to_string(), - Err(err) => { - return return_error_page( - path, - 400, - &fmt_err(&ServerError::UrlDecodeFailed { source: err }), - None, - error_pages, - html_shell.as_ref(), - ) - } - }; - let path = path.as_str(); - 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.get_ref()); - }; - - // Run the routing algorithms on the path to figure out which template we need - // (this *does* check if the locale is supported) - 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 - 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, - }) => { - // We need to turn the Actix Web request into one acceptable for Perseus (uses - // `http` internally) - let http_req = convert_req(&req); - let http_req = match http_req { - Ok(http_req) => http_req, - // If this fails, the client request is malformed, so it's a 400 - Err(err) => { - return html_err(400, &fmt_err(&err)); - } - }; - // Actually render the page as we would if this weren't an initial load - let page_data = get_page_for_template( - GetPageProps { - raw_path: &path, - locale: &locale, - was_incremental_match, - req: http_req, - global_state: &global_state, - immutable_store: immutable_store.get_ref(), - mutable_store: mutable_store.get_ref(), - translations_manager: translations_manager.get_ref(), - global_state_creator: gsc.get_ref(), - }, - template, - true, // This is an initial load, so we do want the content rendered/fetched - ) - .await; - let (page_data, global_state) = 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)); - } - }; - // Get the translations to interpolate into the page - let translations = translations_manager - .get_translations_str_for_locale(locale) - .await; - let translations = match translations { - Ok(translations) => translations, - // We know for sure that this locale is supported, so there's been an internal - // server error if it can't be found - Err(err) => { - return html_err(500, &fmt_err(&err)); - } - }; - - let final_html = html_shell - .get_ref() - .clone() - .page_data(&page_data, &global_state, &translations) - .to_string(); - - let mut http_res = HttpResponse::Ok(); - http_res.content_type("text/html"); - // Generate and add HTTP headers - for (key, val) in template.get_headers(TemplateState::from_value(page_data.state)) { - http_res.insert_header((key.unwrap(), val)); - } - - 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/...` - 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( - html_shell - .get_ref() - .clone() - .locale_redirection_fallback( - // We'll redirect the user to the default locale - &format!( - "{}/{}/{}", - get_path_prefix_server(), - opts.locales.default, - path - ), - ) - .to_string(), - ) - } - 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 41b25fd76f..3641b04c5d 100644 --- a/packages/perseus-actix-web/src/lib.rs +++ b/packages/perseus-actix-web/src/lib.rs @@ -7,17 +7,198 @@ documentation, and this should mostly be used as a secondary reference source. Y */ #![deny(missing_docs)] +#![deny(missing_debug_implementations)] -mod configurer; -mod conv_req; -#[cfg(feature = "dflt-server")] -mod dflt_server; -pub mod errors; -mod initial_load; -mod page_data; -mod translations; +use std::sync::Arc; + +use actix_files::{Files, NamedFile}; +use actix_web::{web, HttpRequest, HttpResponse, Responder}; +use perseus::turbine::ApiResponse as PerseusApiResponse; +use perseus::{ + http::StatusCode, + i18n::TranslationsManager, + path::*, + server::ServerOptions, + stores::MutableStore, + turbine::{SubsequentLoadQueryParams, Turbine}, + Request, +}; + +// ----- Request conversion implementation ----- + +/// Converts an Actix Web request into an `http::request`. +pub fn convert_req(raw: &actix_web::HttpRequest) -> Result<Request, String> { + let mut builder = Request::builder(); + + for (name, val) in raw.headers() { + builder = builder.header(name, val); + } + + builder + .uri(raw.uri()) + .method(raw.method()) + .version(raw.version()) + // We always use an empty body because, in a Perseus request, only the URI matters + // Any custom data should therefore be sent in headers (if you're doing that, consider a + // dedicated API) + .body(()) + .map_err(|err| err.to_string()) +} + +// ----- Newtype wrapper for response implementation ----- + +#[derive(Debug)] +struct ApiResponse(PerseusApiResponse); +impl From<PerseusApiResponse> for ApiResponse { + fn from(val: PerseusApiResponse) -> Self { + Self(val) + } +} +impl Responder for ApiResponse { + type Body = String; + fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> { + let mut res = HttpResponse::build(self.0.status); + for header in self.0.headers { + // The header name is in an `Option`, but we only ever add them with proper + // names in `PerseusApiResponse` + res.insert_header((header.0.unwrap(), header.1)); + } + // TODO + res.message_body(self.0.body).unwrap() + } +} + +// ----- Integration code ----- + +/// 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<M: MutableStore + 'static, T: TranslationsManager + 'static>( + turbine: &'static Turbine<M, T>, + opts: ServerOptions, +) -> impl FnOnce(&mut actix_web::web::ServiceConfig) { + move |cfg: &mut web::ServiceConfig| { + let snippets_dir = opts.snippets.clone(); + cfg + .app_data(web::Data::new(opts)) + // --- File handlers --- + .route("/.perseus/bundle.js", web::get().to(js_bundle)) + .route("/.perseus/bundle.wasm", web::get().to(wasm_bundle)) + .route("/.perseus/bundle.wasm.js", web::get().to(wasm_js_bundle)) + .service(Files::new("/.perseus/snippets", &snippets_dir)) + // --- Translation and subsequent load handlers + .route( + "/.perseus/translations/{locale}", + web::get().to(move |http_req: HttpRequest| async move { + let locale = http_req.match_info().query("locale"); + ApiResponse(turbine.get_translations(locale).await) + }), + ) + .route( + // We capture the `.json` ending in the handler + "/.perseus/page/{locale}/{filename:.*}", + web::get().to(move |http_req: HttpRequest, web::Query(query_params): web::Query<SubsequentLoadQueryParams>| async move { + let raw_path = http_req.match_info().query("filename").to_string(); + let locale = http_req.match_info().query("locale"); + let SubsequentLoadQueryParams { entity_name, was_incremental_match } = query_params; + let http_req = match convert_req(&http_req) { + Ok(req) => req, + Err(err) => return ApiResponse(PerseusApiResponse::err(StatusCode::BAD_REQUEST, &err)) + }; + + ApiResponse(turbine.get_subsequent_load( + PathWithoutLocale(raw_path), + locale.to_string(), + entity_name, + was_incremental_match, + http_req + ).await) + }), + ); + // --- Static directory and alias handlers + if turbine.static_dir.exists() { + cfg.service(Files::new("/.perseus/static", &turbine.static_dir)); + } + for url in turbine.static_aliases.keys() { + cfg.route( + url, + web::get().to(|req| async { static_alias(turbine, req).await }), + ); + } + // --- Initial load handler --- + cfg.route( + "{route:.*}", + web::get().to(move |http_req: HttpRequest| async move { + let raw_path = http_req.path().to_string(); + let http_req = match convert_req(&http_req) { + Ok(req) => req, + Err(err) => { + return ApiResponse(PerseusApiResponse::err(StatusCode::BAD_REQUEST, &err)) + } + }; + ApiResponse( + turbine + .get_initial_load(PathMaybeWithLocale(raw_path), http_req) + .await, + ) + }), + ); + } +} + +// File handlers (these have to be broken out for Actix) +async fn js_bundle(opts: web::Data<ServerOptions>) -> std::io::Result<NamedFile> { + NamedFile::open(&opts.js_bundle) +} +async fn wasm_bundle(opts: web::Data<ServerOptions>) -> std::io::Result<NamedFile> { + NamedFile::open(&opts.wasm_bundle) +} +async fn wasm_js_bundle(opts: web::Data<ServerOptions>) -> std::io::Result<NamedFile> { + NamedFile::open(&opts.wasm_js_bundle) +} +async fn static_alias<M: MutableStore, T: TranslationsManager>( + turbine: &'static Turbine<M, T>, + req: HttpRequest, +) -> std::io::Result<NamedFile> { + let filename = turbine.static_aliases.get(req.path()); + let filename = match filename { + Some(filename) => filename, + // If the path doesn't exist, then the alias is not found + None => return Err(std::io::Error::from(std::io::ErrorKind::NotFound)), + }; + NamedFile::open(filename) +} + +// ----- Default server ----- -pub use crate::configurer::configurer; +/// Creates and starts the default Perseus server using Actix Web. This should +/// be run in a `main()` function annotated with `#[tokio::main]` (which +/// requires the `macros` and `rt-multi-thread` features on the `tokio` +/// dependency). #[cfg(feature = "dflt-server")] -pub use dflt_server::dflt_server; -pub use perseus::server::ServerOptions; +pub async fn dflt_server<M: MutableStore + 'static, T: TranslationsManager + 'static>( + turbine: &'static Turbine<M, T>, + opts: ServerOptions, + (host, port): (String, u16), +) { + use actix_web::{App, HttpServer}; + use futures::executor::block_on; + // TODO Fix issues here + HttpServer::new(move || + App::new() + .configure( + block_on( + configurer( + turbine, + opts.clone(), + ) + ) + ) + ) + .bind((host, port)) + .expect("Couldn't bind to given address. Maybe something is already running on the selected port?") + .run() + .await + .expect("Server failed.") // TODO Improve error message here +} diff --git a/packages/perseus-actix-web/src/page_data.rs b/packages/perseus-actix-web/src/page_data.rs deleted file mode 100644 index 6c1b0970fe..0000000000 --- a/packages/perseus-actix-web/src/page_data.rs +++ /dev/null @@ -1,105 +0,0 @@ -use std::sync::Arc; - -use crate::conv_req::convert_req; -use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse}; -use fmterr::fmt_err; -use perseus::{ - errors::err_to_status_code, - i18n::TranslationsManager, - internal::PageDataPartial, - server::{get_page_for_template, GetPageProps, ServerOptions}, - state::GlobalStateCreator, - stores::{ImmutableStore, MutableStore}, - template::TemplateState, -}; -use serde::Deserialize; - -#[derive(Deserialize)] -pub struct PageDataReq { - pub template_name: String, - pub was_incremental_match: bool, -} - -/// The handler for calls to `.perseus/page/*`. This will manage returning -/// errors and the like. -#[allow(clippy::too_many_arguments)] -pub async fn page_data<M: MutableStore, T: TranslationsManager>( - req: HttpRequest, - opts: web::Data<ServerOptions>, - immutable_store: web::Data<ImmutableStore>, - mutable_store: web::Data<M>, - translations_manager: web::Data<T>, - global_state: web::Data<TemplateState>, - gsc: web::Data<Arc<GlobalStateCreator>>, - web::Query(query_params): web::Query<PageDataReq>, -) -> HttpResponse { - let templates = &opts.templates_map; - let locale = req.match_info().query("locale"); - let PageDataReq { - template_name, - was_incremental_match, - } = query_params; - // Check if the locale is supported - if opts.locales.is_supported(locale) { - let path = req.match_info().query("filename"); - // We need to turn the Actix Web request into one acceptable for Perseus (uses - // `http` internally) - let http_req = convert_req(&req); - let http_req = match http_req { - Ok(http_req) => http_req, - // If this fails, the client request is malformed, so it's a 400 - Err(err) => { - return HttpResponse::build(StatusCode::from_u16(400).unwrap()).body(fmt_err(&err)) - } - }; - // 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 HttpResponse::InternalServerError().body("template not found".to_string()); - } - }; - let page_data = get_page_for_template( - GetPageProps { - raw_path: path, - locale, - was_incremental_match, - req: http_req, - global_state: &global_state, - immutable_store: immutable_store.get_ref(), - mutable_store: mutable_store.get_ref(), - translations_manager: translations_manager.get_ref(), - global_state_creator: gsc.get_ref(), - }, - template, - false, // For subsequent loads, we don't want to render content (the client can do it) - ) - .await; - match page_data { - Ok((page_data, _)) => { - let partial_page_data = PageDataPartial { - state: page_data.state.clone(), - head: page_data.head, - }; - let mut http_res = HttpResponse::Ok(); - http_res.content_type("text/html"); - // Generate and add HTTP headers - for (key, val) in template.get_headers(TemplateState::from_value(page_data.state)) { - http_res.insert_header((key.unwrap(), val)); - } - - http_res.body(serde_json::to_string(&partial_page_data).unwrap()) - } - // We parse the error to return an appropriate status code - Err(err) => { - HttpResponse::build(StatusCode::from_u16(err_to_status_code(&err)).unwrap()) - .body(fmt_err(&err)) - } - } - } else { - HttpResponse::NotFound().body("locale not supported".to_string()) - } -} diff --git a/packages/perseus-actix-web/src/translations.rs b/packages/perseus-actix-web/src/translations.rs deleted file mode 100644 index 255678b5f7..0000000000 --- a/packages/perseus-actix-web/src/translations.rs +++ /dev/null @@ -1,32 +0,0 @@ -use actix_web::{web, HttpRequest, HttpResponse}; -use fmterr::fmt_err; -use perseus::i18n::TranslationsManager; -use perseus::server::ServerOptions; - -/// 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<String, String>` of the -/// translations themselves. -pub async fn translations<T: TranslationsManager>( - req: HttpRequest, - opts: web::Data<ServerOptions>, - translations_manager: web::Data<T>, -) -> HttpResponse { - let locale = req.match_info().query("locale"); - // 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 HttpResponse::InternalServerError().body(fmt_err(&err)), - }; - - HttpResponse::Ok().body(translations) - } else { - HttpResponse::NotFound().body("locale not supported".to_string()) - } -} diff --git a/packages/perseus-axum/Cargo.toml b/packages/perseus-axum/Cargo.toml index c356c35097..72e74074f6 100644 --- a/packages/perseus-axum/Cargo.toml +++ b/packages/perseus-axum/Cargo.toml @@ -16,16 +16,7 @@ categories = ["wasm", "web-programming::http-server", "development-tools", "asyn [dependencies] perseus = { path = "../perseus", version = "0.4.0-beta.11" } axum = "0.6" -tower = "0.4" tower-http = { version = "0.3", features = [ "fs" ] } -urlencoding = "2.1" -serde = "1" -serde_json = "1" -thiserror = "1" -fmterr = "0.1" -futures = "0.3" -sycamore = { version = "^0.8.1", features = ["ssr"] } -closure = "0.3" [features] # Enables the default server configuration, which provides a convenience function if you're not adding any extra routes diff --git a/packages/perseus-axum/src/dflt_server.rs b/packages/perseus-axum/src/dflt_server.rs deleted file mode 100644 index 5986f4e79a..0000000000 --- a/packages/perseus-axum/src/dflt_server.rs +++ /dev/null @@ -1,20 +0,0 @@ -use crate::get_router; -use perseus::{i18n::TranslationsManager, server::ServerProps, stores::MutableStore}; -use std::net::SocketAddr; - -/// Creates and starts the default Perseus server with Axum. This should be run -/// in a `main` function annotated with `#[tokio::main]` (which requires the -/// `macros` and `rt-multi-thread` features on the `tokio` dependency). -pub async fn dflt_server<M: MutableStore + 'static, T: TranslationsManager + 'static>( - props: ServerProps<M, T>, - (host, port): (String, u16), -) { - let addr: SocketAddr = format!("{}:{}", host, port) - .parse() - .expect("Invalid address provided to bind to."); - let app = get_router(props).await; - axum::Server::bind(&addr) - .serve(app.into_make_service()) - .await - .unwrap(); -} diff --git a/packages/perseus-axum/src/initial_load.rs b/packages/perseus-axum/src/initial_load.rs deleted file mode 100644 index 5b05ee23a7..0000000000 --- a/packages/perseus-axum/src/initial_load.rs +++ /dev/null @@ -1,173 +0,0 @@ -use axum::{ - body::Body, - http::{HeaderMap, StatusCode}, - response::Html, -}; -use fmterr::fmt_err; -use perseus::{ - errors::{err_to_status_code, ServerError}, - i18n::{TranslationsManager, Translator}, - router::{match_route_atomic, RouteInfoAtomic, RouteVerdictAtomic}, - server::{ - build_error_page, get_page_for_template, get_path_slice, GetPageProps, HtmlShell, - ServerOptions, - }, - state::GlobalStateCreator, - stores::{ImmutableStore, MutableStore}, - template::TemplateState, - utils::get_path_prefix_server, - ErrorPages, Request, SsrNode, -}; -use std::{collections::HashMap, rc::Rc, sync::Arc}; - -/// 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<Rc<Translator>>, - error_pages: &ErrorPages<SsrNode>, - html_shell: &HtmlShell, -) -> (StatusCode, HeaderMap, Html<String>) { - let html = build_error_page(url, status, err, translator, error_pages, html_shell); - ( - StatusCode::from_u16(status).unwrap(), - HeaderMap::new(), - Html(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. -#[allow(clippy::too_many_arguments)] // As for `page_data_handler`, we don't have a choice -pub async fn initial_load_handler<M: MutableStore, T: TranslationsManager>( - http_req: perseus::http::Request<Body>, - opts: Arc<ServerOptions>, - html_shell: Arc<HtmlShell>, - render_cfg: Arc<HashMap<String, String>>, - immutable_store: Arc<ImmutableStore>, - mutable_store: Arc<M>, - translations_manager: Arc<T>, - global_state: Arc<TemplateState>, - gsc: Arc<GlobalStateCreator>, -) -> (StatusCode, HeaderMap, Html<String>) { - let error_pages = &opts.error_pages; - let path = http_req.uri().path().to_string(); - let path = match urlencoding::decode(&path) { - Ok(path) => path.to_string(), - Err(err) => { - return return_error_page( - path.as_str(), - 400, - &fmt_err(&ServerError::UrlDecodeFailed { source: err }), - None, - error_pages, - html_shell.as_ref(), - ) - } - }; - let path = path.as_str(); - let http_req = Request::from_parts(http_req.into_parts().0, ()); - - let templates = &opts.templates_map; - 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()); - }; - - // 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( - GetPageProps::<M, T> { - raw_path: &path, - locale: &locale, - was_incremental_match, - req: http_req, - global_state: &global_state, - immutable_store: &immutable_store, - mutable_store: &mutable_store, - translations_manager: &translations_manager, - global_state_creator: &gsc, - }, - template, - true, - ) - .await; - let (page_data, global_state) = 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)); - } - }; - // Get the translations to interpolate into the page - let translations = translations_manager - .get_translations_str_for_locale(locale) - .await; - let translations = match translations { - Ok(translations) => translations, - // We know for sure that this locale is supported, so there's been an internal - // server error if it can't be found - Err(err) => { - return html_err(500, &fmt_err(&err)); - } - }; - - let final_html = html_shell - .as_ref() - .clone() - .page_data(&page_data, &global_state, &translations) - .to_string(); - - // http_res.content_type("text/html"); - // Generate and add HTTP headers - let mut header_map = HeaderMap::new(); - for (key, val) in template.get_headers(TemplateState::from_value(page_data.state)) { - header_map.insert(key.unwrap(), val); - } - - (StatusCode::OK, header_map, Html(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/...` - 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 - ( - StatusCode::FOUND, - HeaderMap::new(), - Html( - html_shell - .as_ref() - .clone() - .locale_redirection_fallback( - // We'll redirect the user to the default locale - &format!( - "{}/{}/{}", - get_path_prefix_server(), - opts.locales.default, - path - ), - ) - .to_string(), - ), - ) - } - RouteVerdictAtomic::NotFound => html_err(404, "page not found"), - } -} diff --git a/packages/perseus-axum/src/lib.rs b/packages/perseus-axum/src/lib.rs index 52aa2aaff6..26caace7a2 100644 --- a/packages/perseus-axum/src/lib.rs +++ b/packages/perseus-axum/src/lib.rs @@ -7,17 +7,166 @@ documentation, and this should mostly be used as a secondary reference source. Y */ #![deny(missing_docs)] +#![deny(missing_debug_implementations)] -// This integration doesn't need to convert request types, because we can get -// them straight out of Axum and then just delete the bodies -#[cfg(feature = "dflt-server")] -mod dflt_server; -mod initial_load; -mod page_data; -mod router; -mod translations; +use axum::{ + body::Body, + extract::{Path, Query}, + http::{Request, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, get_service}, + Router, +}; +use perseus::turbine::ApiResponse as PerseusApiResponse; +use perseus::{ + i18n::TranslationsManager, + path::*, + server::ServerOptions, + stores::MutableStore, + turbine::{SubsequentLoadQueryParams, Turbine}, +}; +use tower_http::services::{ServeDir, ServeFile}; + +// ----- Request conversion implementation ----- + +// Not needed, since Axum uses `http::Request` under the hood, and we can just +// change the body type to `()`. + +// ----- Newtype wrapper for response implementation ----- + +#[derive(Debug)] +struct ApiResponse(PerseusApiResponse); +impl From<PerseusApiResponse> for ApiResponse { + fn from(val: PerseusApiResponse) -> Self { + Self(val) + } +} +impl IntoResponse for ApiResponse { + fn into_response(self) -> Response { + // Very convenient! + (self.0.status, self.0.headers, self.0.body).into_response() + } +} + +// ----- Integration code ----- -pub use crate::router::get_router; +/// Gets the `Router` needed to configure an existing Axum app for Perseus, and +/// should be provided after any other routes, as they include a wildcard route. +pub async fn get_router<M: MutableStore + 'static, T: TranslationsManager + 'static>( + turbine: &'static Turbine<M, T>, + opts: ServerOptions, +) -> Router { + let router = Router::new() + // --- File handlers --- + .route( + "/.perseus/bundle.js", + get_service(ServeFile::new(opts.js_bundle.clone())).handle_error(handle_fs_error), + ) + .route( + "/.perseus/bundle.wasm", + get_service(ServeFile::new(opts.wasm_bundle.clone())).handle_error(handle_fs_error), + ) + .route( + "/.perseus/bundle.wasm.js", + get_service(ServeFile::new(opts.wasm_js_bundle.clone())).handle_error(handle_fs_error), + ) + .route( + "/.perseus/snippets/*path", + get_service(ServeDir::new(opts.snippets)).handle_error(handle_fs_error), + ); + // --- Translation and subsequent load handlers --- + let mut router = router + .route( + "/.perseus/translations/:locale", + get(move |Path(locale): Path<String>| async move { + ApiResponse(turbine.get_translations(&locale).await) + }), + ) + .route( + "/.perseus/page/:locale/*tail", + get( + move |Path(path_parts): Path<Vec<String>>, + Query(SubsequentLoadQueryParams { + entity_name, + was_incremental_match, + }): Query<SubsequentLoadQueryParams>, + http_req: Request<Body>| async move { + // Separate the locale from the rest of the page name + let locale = &path_parts[0]; + let raw_path = path_parts[1..] + .iter() + .map(|x| x.as_str()) + .collect::<Vec<&str>>() + .join("/"); + // Get rid of the body from the request (Perseus only needs the metadata) + let req = Request::from_parts(http_req.into_parts().0, ()); + + ApiResponse( + turbine + .get_subsequent_load( + PathWithoutLocale(raw_path), + locale.to_string(), + entity_name, + was_incremental_match, + req, + ) + .await, + ) + }, + ), + ); + // --- Static directory and alias handlers --- + if turbine.static_dir.exists() { + router = router.nest_service( + "/.perseus/static", + get_service(ServeDir::new(&turbine.static_dir)).handle_error(handle_fs_error), + ) + } + for (url, static_path) in turbine.static_aliases.iter() { + router = router.route( + url, // This comes with a leading forward slash! + get_service(ServeFile::new(static_path)).handle_error(handle_fs_error), + ); + } + // --- Initial load handler --- + router.fallback_service(get(move |http_req: Request<Body>| async move { + // Since this is a fallback handler, we have to do everything from the request + // itself + let path = http_req.uri().path().to_string(); + let http_req = Request::from_parts(http_req.into_parts().0, ()); + + ApiResponse( + turbine + .get_initial_load(PathMaybeWithLocale(path), http_req) + .await, + ) + })) +} + +// TODO Review if there's anything more to do here +async fn handle_fs_error(_err: std::io::Error) -> impl IntoResponse { + (StatusCode::INTERNAL_SERVER_ERROR, "Couldn't serve file.") +} + +// ----- Default server ----- + +/// Creates and starts the default Perseus server with Axum. This should be run +/// in a `main` function annotated with `#[tokio::main]` (which requires the +/// `macros` and `rt-multi-thread` features on the `tokio` dependency). #[cfg(feature = "dflt-server")] -pub use dflt_server::dflt_server; -pub use perseus::server::ServerOptions; +pub async fn dflt_server<M: MutableStore + 'static, T: TranslationsManager + 'static>( + turbine: &'static Turbine<M, T>, + opts: ServerOptions, + (host, port): (String, u16), +) { + use std::net::SocketAddr; + + let addr: SocketAddr = format!("{}:{}", host, port) + .parse() + .expect("Invalid address provided to bind to."); + let app = get_router(turbine, opts).await; + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .unwrap(); +} diff --git a/packages/perseus-axum/src/page_data.rs b/packages/perseus-axum/src/page_data.rs deleted file mode 100644 index 82b25cf1d7..0000000000 --- a/packages/perseus-axum/src/page_data.rs +++ /dev/null @@ -1,128 +0,0 @@ -use axum::{ - body::Body, - extract::{Path, Query}, - http::{HeaderMap, StatusCode}, -}; -use fmterr::fmt_err; -use perseus::{ - errors::err_to_status_code, - i18n::TranslationsManager, - internal::PageDataPartial, - server::{get_page_for_template, GetPageProps, ServerOptions}, - state::GlobalStateCreator, - stores::{ImmutableStore, MutableStore}, - template::TemplateState, - Request, -}; -use serde::Deserialize; -use std::sync::Arc; - -// Note: this is the same as for the Actix Web integration, but other frameworks -// may handle parsing query parameters differently, 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 Axum extractors work, we don't exactly have a choice -pub async fn page_handler<M: MutableStore, T: TranslationsManager>( - Path(path_parts): Path<Vec<String>>, /* From this, we can extract the locale and the path - * tail (the page path, which *does* have slashes) */ - Query(PageDataReq { - template_name, - was_incremental_match, - }): Query<PageDataReq>, - // This works without any conversion because Axum allows us to directly get an `http::Request` - // out! - http_req: perseus::http::Request<Body>, - opts: Arc<ServerOptions>, - immutable_store: Arc<ImmutableStore>, - mutable_store: Arc<M>, - translations_manager: Arc<T>, - global_state: Arc<TemplateState>, - gsc: Arc<GlobalStateCreator>, -) -> (StatusCode, HeaderMap, String) { - // Separate the locale from the rest of the page name - let locale = &path_parts[0]; - let path = path_parts[1..] - .iter() - .map(|x| x.as_str()) - .collect::<Vec<&str>>() - .join("/"); - // Axum's paths have leading slashes - // As of 0.6, they do not always have slashes, see #1086 in tokio-rs/axum - let path = path.strip_prefix('/').unwrap_or(&path); - - 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.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 ( - StatusCode::INTERNAL_SERVER_ERROR, - HeaderMap::new(), - "template not found".to_string(), - ); - } - }; - // Convert the request into one palatable for Perseus (which doesn't have the - // body attached) - let http_req = Request::from_parts(http_req.into_parts().0, ()); - let page_data = get_page_for_template( - GetPageProps::<M, T> { - raw_path: path, - locale, - was_incremental_match, - req: http_req, - global_state: &global_state, - immutable_store: &immutable_store, - mutable_store: &mutable_store, - translations_manager: &translations_manager, - global_state_creator: &gsc, - }, - template, - false, - ) - .await; - match page_data { - Ok((page_data, _)) => { - let partial_page_data = PageDataPartial { - state: page_data.state.clone(), - head: page_data.head, - }; - // http_res.content_type("text/html"); - // Generate and add HTTP headers - let mut header_map = HeaderMap::new(); - for (key, val) in template.get_headers(TemplateState::from_value(page_data.state)) { - header_map.insert(key.unwrap(), val); - } - - let page_data_str = serde_json::to_string(&partial_page_data).unwrap(); - - (StatusCode::OK, header_map, page_data_str) - } - // We parse the error to return an appropriate status code - Err(err) => ( - StatusCode::from_u16(err_to_status_code(&err)).unwrap(), - HeaderMap::new(), - fmt_err(&err), - ), - } - } else { - ( - StatusCode::NOT_FOUND, - HeaderMap::new(), - "locale not supported".to_string(), - ) - } -} diff --git a/packages/perseus-axum/src/router.rs b/packages/perseus-axum/src/router.rs deleted file mode 100644 index 6f2fec4c1b..0000000000 --- a/packages/perseus-axum/src/router.rs +++ /dev/null @@ -1,138 +0,0 @@ -use crate::initial_load::initial_load_handler; -use crate::page_data::page_handler; -use crate::translations::translations_handler; -use axum::{ - http::StatusCode, - response::IntoResponse, - routing::{get, get_service}, - Router, -}; -use closure::closure; -use perseus::{i18n::TranslationsManager, stores::MutableStore}; -use perseus::{ - server::{get_render_cfg, ServerProps}, - state::get_built_global_state, -}; -use std::sync::Arc; -use tower_http::services::{ServeDir, ServeFile}; - -/// Gets the `Router` needed to configure an existing Axum app for Perseus, and -/// should be provided after any other routes, as they include a wildcard route. -pub async fn get_router<M: MutableStore + 'static, T: TranslationsManager + 'static>( - ServerProps { - opts, - immutable_store, - mutable_store, - translations_manager, - global_state_creator, - }: ServerProps<M, T>, -) -> Router { - let render_cfg = get_render_cfg(&immutable_store) - .await - .expect("Couldn't get render configuration!"); - let index_with_render_cfg = opts.html_shell.clone(); - // Generate the global state - let global_state = get_built_global_state(&immutable_store) - .await - .expect("couldn't get pre-built global state or placeholder (the app's build artifacts have almost certainly been corrupted)"); - - let immutable_store = Arc::new(immutable_store); - let mutable_store = Arc::new(mutable_store); - let translations_manager = Arc::new(translations_manager); - let html_shell = Arc::new(index_with_render_cfg); - let render_cfg = Arc::new(render_cfg); - let global_state = Arc::new(global_state); - - let static_dir = opts.static_dir.clone(); - let static_aliases = opts.static_aliases.clone(); - - let router = Router::new() - .route( - "/.perseus/bundle.js", - get_service(ServeFile::new(opts.js_bundle.clone())).handle_error(handle_fs_error), - ) - .route( - "/.perseus/bundle.wasm", - get_service(ServeFile::new(opts.wasm_bundle.clone())).handle_error(handle_fs_error), - ) - .route( - "/.perseus/bundle.wasm.js", - get_service(ServeFile::new(opts.wasm_js_bundle.clone())).handle_error(handle_fs_error), - ) - .route( - "/.perseus/snippets/*path", - get_service(ServeDir::new(opts.snippets.clone())).handle_error(handle_fs_error), - ); - let opts = Arc::new(opts); - let mut router = router - .route( - "/.perseus/translations/:locale", - get(closure!(clone opts, clone translations_manager, |path| translations_handler::<T>(path, opts, translations_manager))), - ) - .route("/.perseus/page/:locale/*tail", get( - closure!( - clone opts, - clone immutable_store, - clone mutable_store, - clone translations_manager, - clone global_state, - clone global_state_creator, - |path, query, http_req| - page_handler::<M, T>( - path, - query, - http_req, - opts, - immutable_store, - mutable_store, - translations_manager, - global_state, - global_state_creator, - ) - ) - )); - // Only add the static content directory route if such a directory is being used - if let Some(static_dir) = static_dir { - router = router.nest_service( - "/.perseus/static", - get_service(ServeDir::new(static_dir)).handle_error(handle_fs_error), - ) - } - // Now add support for serving static aliases - for (url, static_path) in static_aliases.iter() { - // Note that `static_path` is already relative to the right place - // (`.perseus/server/`) - router = router.route( - url, // This comes with a leading forward slash! - get_service(ServeFile::new(static_path)).handle_error(handle_fs_error), - ); - } - // And add the fallback for initial loads - router.fallback_service(get(closure!( - clone opts, - clone html_shell, - clone render_cfg, - clone immutable_store, - clone mutable_store, - clone translations_manager, - clone global_state, - clone global_state_creator, - |http_req| - initial_load_handler::<M, T>( - http_req, - opts, - html_shell, - render_cfg, - immutable_store, - mutable_store, - translations_manager, - global_state, - global_state_creator, - ) - ))) -} - -// TODO Review if there's anything more to do here -async fn handle_fs_error(_err: std::io::Error) -> impl IntoResponse { - (StatusCode::INTERNAL_SERVER_ERROR, "Couldn't serve file.") -} diff --git a/packages/perseus-axum/src/translations.rs b/packages/perseus-axum/src/translations.rs deleted file mode 100644 index 3999018d36..0000000000 --- a/packages/perseus-axum/src/translations.rs +++ /dev/null @@ -1,27 +0,0 @@ -use axum::{extract::Path, http::StatusCode}; -use fmterr::fmt_err; -use perseus::{i18n::TranslationsManager, server::ServerOptions}; -use std::sync::Arc; - -pub async fn translations_handler<T: TranslationsManager>( - Path(locale): Path<String>, - opts: Arc<ServerOptions>, - translations_manager: Arc<T>, -) -> (StatusCode, String) { - // 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 (StatusCode::INTERNAL_SERVER_ERROR, fmt_err(&err)), - }; - - (StatusCode::OK, translations) - } else { - (StatusCode::NOT_FOUND, "locale not supported".to_string()) - } -} diff --git a/packages/perseus-cli/src/bin/main.rs b/packages/perseus-cli/src/bin/main.rs index ff62a7fcbb..d6891fd7be 100644 --- a/packages/perseus-cli/src/bin/main.rs +++ b/packages/perseus-cli/src/bin/main.rs @@ -269,6 +269,7 @@ async fn core_watch(dir: PathBuf, opts: Opts) -> Result<i32, Error> { let tools = Tools::new(&dir, &opts).await?; // Delete old build artifacts delete_artifacts(dir.clone(), "static")?; + delete_artifacts(dir.clone(), "mutable")?; build(dir, build_opts, &tools, &opts)? } Subcommand::Export(ref export_opts) => { @@ -276,6 +277,7 @@ async fn core_watch(dir: PathBuf, opts: Opts) -> Result<i32, Error> { let tools = Tools::new(&dir, &opts).await?; // Delete old build/export artifacts delete_artifacts(dir.clone(), "static")?; + delete_artifacts(dir.clone(), "mutable")?; delete_artifacts(dir.clone(), "exported")?; let exit_code = export(dir.clone(), export_opts, &tools, &opts)?; if exit_code != 0 { @@ -302,6 +304,7 @@ async fn core_watch(dir: PathBuf, opts: Opts) -> Result<i32, Error> { let tools = Tools::new(&dir, &opts).await?; if !serve_opts.no_build { delete_artifacts(dir.clone(), "static")?; + delete_artifacts(dir.clone(), "mutable")?; } // This orders reloads internally let (exit_code, _server_path) = serve(dir, serve_opts, &tools, &opts)?; @@ -315,6 +318,7 @@ async fn core_watch(dir: PathBuf, opts: Opts) -> Result<i32, Error> { // Delete old build artifacts if `--no-build` wasn't specified if !test_opts.no_build { delete_artifacts(dir.clone(), "static")?; + delete_artifacts(dir.clone(), "mutable")?; } let (exit_code, _server_path) = serve(dir, test_opts, &tools, &opts)?; exit_code @@ -331,6 +335,7 @@ async fn core_watch(dir: PathBuf, opts: Opts) -> Result<i32, Error> { create_dist(&dir)?; let tools = Tools::new(&dir, &opts).await?; delete_artifacts(dir.clone(), "static")?; + delete_artifacts(dir.clone(), "mutable")?; delete_artifacts(dir.clone(), "exported")?; delete_artifacts(dir.clone(), "pkg")?; deploy(dir, deploy_opts, &tools, &opts)? @@ -371,6 +376,7 @@ async fn core_watch(dir: PathBuf, opts: Opts) -> Result<i32, Error> { let tools = Tools::new(&dir, &opts).await?; // Delete old build artifacts delete_artifacts(dir.clone(), "static")?; + delete_artifacts(dir.clone(), "mutable")?; check(dir, check_opts, &tools, &opts)? } }; diff --git a/packages/perseus-cli/src/deploy.rs b/packages/perseus-cli/src/deploy.rs index b02d8651ca..4e6fbd6777 100644 --- a/packages/perseus-cli/src/deploy.rs +++ b/packages/perseus-cli/src/deploy.rs @@ -121,13 +121,13 @@ fn deploy_full( } } // Create the `dist/` directory in the output directory - if let Err(err) = fs::create_dir(&output_path.join("dist")) { + if let Err(err) = fs::create_dir(output_path.join("dist")) { return Err(DeployError::CreateDistDirFailed { source: err }.into()); } // Copy in the different parts of the `dist/` directory that we need (they all // have to exist) let from = dir.join("dist/static"); - if let Err(err) = copy_dir(&from, &output_path.join("dist"), &CopyOptions::new()) { + if let Err(err) = copy_dir(&from, output_path.join("dist"), &CopyOptions::new()) { return Err(DeployError::MoveDirFailed { to: output, from: from.to_str().map(|s| s.to_string()).unwrap(), @@ -136,7 +136,7 @@ fn deploy_full( .into()); } let from = dir.join("dist/pkg"); // Note: this handles snippets and the like - if let Err(err) = copy_dir(&from, &output_path.join("dist"), &CopyOptions::new()) { + if let Err(err) = copy_dir(&from, output_path.join("dist"), &CopyOptions::new()) { return Err(DeployError::MoveDirFailed { to: output, from: from.to_str().map(|s| s.to_string()).unwrap(), @@ -145,7 +145,7 @@ fn deploy_full( .into()); } let from = dir.join("dist/render_conf.json"); - if let Err(err) = fs::copy(&from, &output_path.join("dist/render_conf.json")) { + if let Err(err) = fs::copy(&from, output_path.join("dist/render_conf.json")) { return Err(DeployError::MoveAssetFailed { to: output, from: from.to_str().map(|s| s.to_string()).unwrap(), diff --git a/packages/perseus-cli/src/init.rs b/packages/perseus-cli/src/init.rs index 4426117a21..a9d8da56b6 100644 --- a/packages/perseus-cli/src/init.rs +++ b/packages/perseus-cli/src/init.rs @@ -144,7 +144,7 @@ perseus-warp = { version = "=%perseus_version", features = [ "dflt-server" ] } static DFLT_INIT_GITIGNORE: &str = r#"dist/"#; static DFLT_INIT_MAIN_RS: &str = r#"mod templates; -use perseus::{Html, PerseusApp}; +use perseus::prelude::*; #[perseus::main(perseus_warp::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { @@ -177,5 +177,5 @@ fn head(cx: Scope) -> View<SsrNode> { } pub fn get_template<G: Html>() -> Template<G> { - Template::new("index").template(index_page).head(head) + Template::build("index").template(index_page).head(head) }"#; diff --git a/packages/perseus-cli/src/install.rs b/packages/perseus-cli/src/install.rs index 1c5f026ea1..b4d92c5fbe 100644 --- a/packages/perseus-cli/src/install.rs +++ b/packages/perseus-cli/src/install.rs @@ -460,7 +460,7 @@ impl Tool { "https://api.github.com/repos/{}/releases/latest", self.gh_repo )) - // TODO Is this compliant with GH's ToS? + // This needs to display the name of the app for GH .header("User-Agent", "perseus-cli") .send() .await diff --git a/packages/perseus-cli/src/serve_exported.rs b/packages/perseus-cli/src/serve_exported.rs index daccf4c04f..d4a5b15c57 100644 --- a/packages/perseus-cli/src/serve_exported.rs +++ b/packages/perseus-cli/src/serve_exported.rs @@ -58,7 +58,7 @@ pub async fn serve_exported( port = port ); - let _ = warp::serve(files).run(addr).await; + warp::serve(files).run(addr).await; // We will never get here (the above runs forever) Ok(0) } diff --git a/packages/perseus-macro/Cargo.toml b/packages/perseus-macro/Cargo.toml index 04ea84a0f0..dc89bcccc5 100644 --- a/packages/perseus-macro/Cargo.toml +++ b/packages/perseus-macro/Cargo.toml @@ -15,18 +15,11 @@ categories = ["wasm", "web-programming", "development-tools", "asynchronous", "g [lib] proc-macro = true -# [[test]] -# name = "tests" -# path = "tests/progress.rs" - [dependencies] quote = "1" syn = "1" proc-macro2 = "1" darling = "0.13" -serde_json = "1" -sycamore-reactive = "^0.8.1" -regex = "1" [dev-dependencies] trybuild = { version = "1.0", features = ["diff"] } diff --git a/packages/perseus-macro/src/template.rs b/packages/perseus-macro/src/auto_scope.rs similarity index 58% rename from packages/perseus-macro/src/template.rs rename to packages/perseus-macro/src/auto_scope.rs index 6d34d5b748..9dad720d01 100644 --- a/packages/perseus-macro/src/template.rs +++ b/packages/perseus-macro/src/auto_scope.rs @@ -1,13 +1,9 @@ -use std::str::FromStr; - -use darling::ToTokens; -use proc_macro2::{Span, TokenStream}; +use proc_macro2::TokenStream; use quote::quote; -use regex::Regex; use syn::parse::{Parse, ParseStream}; use syn::{ - Attribute, Block, FnArg, Generics, Ident, Item, ItemFn, PatType, Result, ReturnType, Type, - TypeTuple, Visibility, + Attribute, Block, FnArg, Ident, Item, ItemFn, PatType, Result, ReturnType, Type, TypeReference, + Visibility, }; /// A function that can be wrapped in the Perseus test sub-harness. @@ -26,9 +22,6 @@ pub struct TemplateFn { pub name: Ident, /// The return type of the function. pub return_type: Box<Type>, - /// Any generics the function takes (should be one for the Sycamore - /// `GenericNode`). - pub generics: Generics, } impl Parse for TemplateFn { fn parse(input: ParseStream) -> Result<Self> { @@ -82,10 +75,11 @@ impl Parse for TemplateFn { } args.push(arg.clone()) } - // We can have 2 arguments only (scope, state) + // We can have 2 arguments only (scope, state), or 3 if it's + // a capsule // Any other kind of template doesn't need this macro - if args.len() != 2 { - return Err(syn::Error::new_spanned(&sig.inputs, "you only need to use `[#template]` if you're using reactive state (which requires two arguments)")); + if args.len() != 2 && args.len() != 3 { + return Err(syn::Error::new_spanned(&sig.inputs, "`#[template]` is only useful if you're using reactive state (which requires two arguments)")); } Ok(Self { @@ -95,7 +89,6 @@ impl Parse for TemplateFn { attrs, name: sig.ident, return_type, - generics: sig.generics, }) } item => Err(syn::Error::new_spanned( @@ -106,68 +99,39 @@ impl Parse for TemplateFn { } } -/// Converts the user-given name of a final reactive `struct` with lifetimes -/// into the same type, just without those lifetimes, so we can use it outside -/// the scope in which those lifetimes have been defined. -/// -/// See the callers of this function to see exactly why it's necessary. -fn remove_lifetimes(ty: &Type) -> Type { - // Don't run any transformation if this is the unit type - match ty { - Type::Tuple(TypeTuple { elems, .. }) if elems.is_empty() => ty.clone(), - _ => { - let ty_str = ty.to_token_stream().to_string(); - // Remove any lifetimes from the type (anything in angular brackets beginning - // with `'`) This regex just removes any lifetimes next to generics - // or on their own, allowing for the whitespace Syn seems to insert - let ty_str = Regex::new(r#"(('.*?) |<\s*('[^, ]*?)\s*>)"#) - .unwrap() - .replace_all(&ty_str, ""); - Type::Verbatim(TokenStream::from_str(&ty_str).unwrap()) - } - } -} - pub fn template_impl(input: TemplateFn) -> TokenStream { let TemplateFn { block, // We know that these are all typed (none are `self`) args: fn_args, - generics, vis, attrs, name, return_type, } = input; - let component_name = Ident::new(&(name.to_string() + "_component"), Span::call_site()); - - // Get the argument for the reactive scope - let cx_arg = &fn_args[0]; - // There's an argument for page properties that needs to have state extracted, - // so the wrapper will deserialize it We'll also make it reactive and - // add it to the page state store let arg = &fn_args[1]; - let rx_props_ty = match arg { - FnArg::Typed(PatType { ty, .. }) => remove_lifetimes(ty), + let state_arg = match arg { + FnArg::Typed(PatType { ty, .. }) => match &**ty { + Type::Reference(TypeReference { elem, .. }) => elem, + _ => return syn::Error::new_spanned(arg, "the state argument must be a reference (e.g. `&MyStateTypeRx`); if you're using unreactive state (i.e. you're deriving `UnreactiveState` instead of `ReactiveState`), you don't need this macro!").to_compile_error() + }, FnArg::Receiver(_) => unreachable!(), }; + let props_arg = match fn_args.get(2) { + Some(arg) => quote!( #arg ), + None => quote!(), + }; quote! { - #vis fn #name<G: ::sycamore::prelude::Html>( - cx: ::sycamore::prelude::Scope, - state: <#rx_props_ty as ::perseus::state::RxRef>::RxNonRef + // All we do is set up the lifetimes correctly + #(#attrs)* + #vis fn #name<'__page, G: ::sycamore::prelude::Html>( + cx: ::sycamore::prelude::BoundedScope<'_, '__page>, + state: &'__page #state_arg, + // Capsules have another argument for properties + #props_arg ) -> #return_type { - use ::perseus::state::MakeRxRef; - - // The user's function, with Sycamore component annotations and the like preserved - // We know this won't be async because Sycamore doesn't allow that - #(#attrs)* - #[::sycamore::component] - fn #component_name #generics(#cx_arg, #arg) -> #return_type { - #block - } - - #component_name(cx, state.to_ref_struct(cx)) + #block } } } diff --git a/packages/perseus-macro/src/entrypoint.rs b/packages/perseus-macro/src/entrypoint.rs index fc95ab7904..d4183fad7a 100644 --- a/packages/perseus-macro/src/entrypoint.rs +++ b/packages/perseus-macro/src/entrypoint.rs @@ -182,7 +182,8 @@ pub fn main_impl(input: MainFn, server_fn: Path) -> TokenStream { // The browser-specific `main` function #[cfg(target_arch = "wasm32")] pub fn main() -> ::perseus::ClientReturn { - ::perseus::run_client(__perseus_simple_main) + ::perseus::run_client(__perseus_simple_main); + Ok(()) } // The user's function (which gets the `PerseusApp`) @@ -220,7 +221,8 @@ pub fn main_export_impl(input: MainFn) -> TokenStream { // The browser-specific `main` function #[cfg(target_arch = "wasm32")] pub fn main() -> ::perseus::ClientReturn { - ::perseus::run_client(__perseus_simple_main) + ::perseus::run_client(__perseus_simple_main); + Ok(()) } // The user's function (which gets the `PerseusApp`) diff --git a/packages/perseus-macro/src/lib.rs b/packages/perseus-macro/src/lib.rs index 6e35380983..a4486c634a 100644 --- a/packages/perseus-macro/src/lib.rs +++ b/packages/perseus-macro/src/lib.rs @@ -11,9 +11,9 @@ This is the API documentation for the `perseus-macro` package, which manages Per documentation, and this should mostly be used as a secondary reference source. You can also find full usage examples [here](https://github.com/arctic-hen7/perseus/tree/main/examples). */ +mod auto_scope; mod entrypoint; mod rx_state; -mod template; mod test; use darling::{FromDeriveInput, FromMeta}; @@ -36,22 +36,46 @@ pub fn template_rx(_args: TokenStream, _input: TokenStream) -> TokenStream { /// A helper macro for templates that use reactive state. Once, this was needed /// on all Perseus templates, however, today, templates that take no state, or /// templates that take unreactive state, can be provided as normal functions -/// to the methods `.template()` and `.template_with_unreactive_state()` +/// to the methods `.view()` and `.view_with_unreactive_state()` /// respectively, on Perseus' `Template` type. /// -/// The only function of this macro is to convert the provided intermediate -/// reactive type to a reference reactive type (see the book to learn more about -/// Perseus' state platform). +/// In fact, even if you're using fully reactive state, this macro isn't even +/// mandated anymore! It just exists to turn function signatures like this /// -/// For those coming from Sycamore, be aware that Perseus templates are *not* -/// Sycamore components, they are normal functions that return a Sycamore -/// `View<G>`. +/// ```text +/// fn my_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a MyStateRx) -> View<G> +/// ``` /// -/// *Note:* this macro will eventually be removed entirely. +/// into this +/// +/// ```text +/// #[auto_scope] +/// fn my_page<G: Html>(cx: Scope, state: &MyStateRx) -> View<G> +/// ``` +/// +/// In other words, all this does is rewrites some lifetimes for you so Perseus +/// is a little more convenient to use! It's worth remembering, however, when +/// you use this macro, that the `Scope` is actually a `BoundedScope<'app, +/// 'page>`, meaning it is a *child scope* of the whole app. Your state is a +/// reference with the lifetime `'page`, which links to an owned type that the +/// app controls. All this lifetime complexity is needed to make sure Rust +/// understands that all your pages are part of your app, and that, when one of +/// your users goes to a new page, the previous page will be dropped, along with +/// all its artifacts (e.g. any `create_effect` calls). It also makes it really +/// convenient to use your state, because we can prove to Sycamore that it will +/// live long enough to be interpolated anywhere in your page's `view!`. +/// +/// If you dislike macros, or if you want to make the lifetimes of a page very +/// clear, it's recommended that you don't use this macro, and manually write +/// the longer function signatures instead. However, if you like the convenience +/// of it, this macro is here to help! +/// +/// *Note: this can also be used for capsules that take reactive state, it's not +/// just limited to templates.* #[proc_macro_attribute] -pub fn template(_args: TokenStream, input: TokenStream) -> TokenStream { - let parsed = syn::parse_macro_input!(input as template::TemplateFn); - template::template_impl(parsed).into() +pub fn auto_scope(_args: TokenStream, input: TokenStream) -> TokenStream { + let parsed = syn::parse_macro_input!(input as auto_scope::TemplateFn); + auto_scope::template_impl(parsed).into() } /// Marks the given function as a Perseus test. Functions marked with this diff --git a/packages/perseus-macro/src/rx_state.rs b/packages/perseus-macro/src/rx_state.rs index 3ca56be5ea..1630ff2763 100644 --- a/packages/perseus-macro/src/rx_state.rs +++ b/packages/perseus-macro/src/rx_state.rs @@ -66,9 +66,7 @@ pub fn make_rx_impl(input: ReactiveStateDeriveInput) -> TokenStream { // Now go through them and create what we want for both the intermediate and the // reactive `struct`s let mut intermediate_fields = quote!(); - let mut ref_fields = quote!(); let mut intermediate_field_makers = quote!(); - let mut ref_field_makers = quote!(); // These start at the intermediate let mut unrx_field_makers = quote!(); let mut suspense_commands = quote!(); for field in fields.iter() { @@ -87,12 +85,7 @@ pub fn make_rx_impl(input: ReactiveStateDeriveInput) -> TokenStream { #field_attrs #field_vis #field_ident: <#old_ty as ::perseus::state::MakeRx>::Rx, }); - ref_fields.extend(quote! { - #field_attrs - #field_vis #field_ident: <<#old_ty as ::perseus::state::MakeRx>::Rx as ::perseus::state::MakeRxRef>::RxRef<'__derived_rx>, - }); intermediate_field_makers.extend(quote! { #field_ident: self.#field_ident.make_rx(), }); - ref_field_makers.extend(quote! { #field_ident: self.#field_ident.to_ref_struct(cx), }); unrx_field_makers .extend(quote! { #field_ident: self.#field_ident.clone().make_unrx(), }); @@ -106,7 +99,7 @@ pub fn make_rx_impl(input: ReactiveStateDeriveInput) -> TokenStream { self.#field_ident.clone(), #handler( cx, - self.#field_ident.clone().to_ref_struct(cx), + ::sycamore::prelude::create_ref(cx, self.#field_ident.clone()), ), ); }); @@ -122,16 +115,9 @@ pub fn make_rx_impl(input: ReactiveStateDeriveInput) -> TokenStream { #field_attrs #field_vis #field_ident: ::sycamore::prelude::RcSignal<#old_ty>, }); - ref_fields.extend(quote! { - #field_attrs - #field_vis #field_ident: &'__derived_rx ::sycamore::prelude::RcSignal<#old_ty>, - }); intermediate_field_makers.extend( quote! { #field_ident: ::sycamore::prelude::create_rc_signal(self.#field_ident), }, ); - ref_field_makers.extend( - quote! { #field_ident: ::sycamore::prelude::create_ref(cx, self.#field_ident), }, - ); // All fields must be `Clone` unrx_field_makers .extend(quote! { #field_ident: (*self.#field_ident.get_untracked()).clone(), }); @@ -170,13 +156,15 @@ pub fn make_rx_impl(input: ReactiveStateDeriveInput) -> TokenStream { &(ident.to_string() + "PerseusRxIntermediate"), Span::call_site(), ); - let ref_ident = Ident::new(&(ident.to_string() + "PerseusRxRef"), Span::call_site()); // Create a type alias for the final reactive version for convenience, if the // user asked for one let ref_alias = if let Some(alias) = alias { - // We use the full form for a cleaner expansion in IDEs - quote! { #vis type #alias<'__derived_rx> = <<#ident as ::perseus::state::MakeRx>::Rx as ::perseus::state::MakeRxRef>::RxRef<'__derived_rx>; } + // // We use the full form for a cleaner expansion in IDEs + // quote! { #vis type #alias<'__derived_rx> = <<#ident as + // ::perseus::state::MakeRx>::Rx as + // ::perseus::state::MakeRxRef>::RxRef<'__derived_rx>; } + quote! { #vis type #alias = #intermediate_ident; } } else { quote!() }; @@ -189,11 +177,6 @@ pub fn make_rx_impl(input: ReactiveStateDeriveInput) -> TokenStream { #intermediate_fields } - #attrs - #vis struct #ref_ident<'__derived_rx> { - #ref_fields - } - impl ::perseus::state::MakeRx for #ident { type Rx = #intermediate_ident; fn make_rx(self) -> Self::Rx { @@ -213,7 +196,6 @@ pub fn make_rx_impl(input: ReactiveStateDeriveInput) -> TokenStream { } #[cfg(target_arch = "wasm32")] fn compute_suspense<'a>(&self, cx: ::sycamore::prelude::Scope<'a>) { - use ::perseus::state::MakeRxRef; // Needs to be in scope #suspense_commands } } @@ -225,338 +207,7 @@ pub fn make_rx_impl(input: ReactiveStateDeriveInput) -> TokenStream { ::serde_json::to_string(&unrx).unwrap() } } - impl ::perseus::state::MakeRxRef for #intermediate_ident { - type RxRef<'__derived_rx> = #ref_ident<'__derived_rx>; - fn to_ref_struct<'__derived_rx>(self, cx: ::sycamore::prelude::Scope<'__derived_rx>) -> Self::RxRef<'__derived_rx> { - Self::RxRef { - #ref_field_makers - } - } - } - impl<'__derived_rx> ::perseus::state::RxRef for #ref_ident<'__derived_rx> { - type RxNonRef = #intermediate_ident; - } #ref_alias } - - // // Note: we create three `struct`s with this macro: the original, the new - // one // (with references), and the new one (intermediary without - // references, stored // in context) So that we don't have to worry - // about unit structs or unnamed // fields, we'll just copy the struct - // and change the parts we want to // We won't create the final `struct` - // yet to avoid more operations than // necessary - // // Note that we leave this as whatever visibility the original state was - // to // avoid compiler errors (since it will be exposed as a - // trait-linked type // through the ref struct) - // let mut mid_struct = orig_struct.clone(); // This will use `RcSignal`s, - // and will be stored in context let ItemStruct { - // ident: orig_name, - // generics, - // .. - // } = orig_struct.clone(); - // // The name of the final reference `struct`'s type alias - // let ref_name = helpers.name.unwrap_or_else(|| - // Ident::new(&(orig_name.to_string() + "Rx"), Span::call_site())); // The - // intermediate struct shouldn't be easily accessible let mid_name = - // Ident::new( &(orig_name.to_string() + "PerseusRxIntermediary"), - // Span::call_site(), - // ); - // mid_struct.ident = mid_name.clone(); - // // Look through the attributes for any that warn about nested fields - // // These can't exist on the fields themselves because they'd be parsed - // before // this macro, and they're technically invalid syntax (grr.) - // When we come // across these fields, we'll run `.make_rx()` on them - // instead of naively // wrapping them in an `RcSignal` - // let nested_fields = mid_struct - // .attrs - // .iter() - // // We only care about our own attributes - // .filter(|attr| { - // attr.path.segments.len() == 2 - // && attr.path.segments.first().unwrap().ident == "rx" - // && attr.path.segments.last().unwrap().ident == "nested" - // }) - // // Remove any attributes that can't be parsed as a `MetaList`, - // returning the internal list // of what can (the 'arguments' to - // the attribute) We need them to be two elements // long (a field - // name and a wrapper type) .filter_map(|attr| match - // attr.parse_meta() { Ok(Meta::List(list)) if list.nested.len() - // == 2 => Some(list.nested), _ => None, - // }) - // // Now parse the tokens within these to an `(Ident, Ident)`, the - // first being the name of the // field and the second being the - // wrapper type to use .map(|meta_list| { - // // Extract field name and wrapper type (we know this only has two - // elements) let field_name = match meta_list.first().unwrap() { - // NestedMeta::Lit(Lit::Str(s)) => - // Ident::new(s.value().as_str(), Span::call_site()), - // NestedMeta::Lit(val) => { return - // Err(syn::Error::new_spanned( val, - // "first argument must be string literal field name", - // )) - // } - // NestedMeta::Meta(meta) => { - // return Err(syn::Error::new_spanned( - // meta, - // "first argument must be string literal field name", - // )) - // } - // }; - // let wrapper_ty = match meta_list.last().unwrap() { - // // TODO Is this `.unwrap()` actually safe to use? - // NestedMeta::Meta(meta) => - // &meta.path().segments.first().unwrap().ident, - // NestedMeta::Lit(val) => { return - // Err(syn::Error::new_spanned( val, - // "second argument must be reactive wrapper type", - // )) - // } - // }; - - // Ok::<(Ident, Ident), syn::Error>((field_name, - // wrapper_ty.clone())) }) - // .collect::<Vec<Result<(Ident, Ident)>>>(); - // // Handle any errors produced by that final transformation and create a - // map let mut nested_fields_map = HashMap::new(); - // for res in nested_fields { - // match res { - // Ok((k, v)) => nested_fields_map.insert(k, v), - // Err(err) => return err.to_compile_error(), - // }; - // } - // // Now remove our attributes from all the `struct`s - // let mut filtered_attrs = Vec::new(); - // for attr in orig_struct.attrs.iter() { - // if !(attr.path.segments.len() == 2 - // && attr.path.segments.first().unwrap().ident == "rx" - // && attr.path.segments.last().unwrap().ident == "nested") - // { - // filtered_attrs.push(attr.clone()); - // } - // } - // orig_struct.attrs = filtered_attrs.clone(); - // mid_struct.attrs = filtered_attrs; - - // // Now define the final `struct` that uses references - // let mut ref_struct = mid_struct.clone(); - // ref_struct.ident = ref_name.clone(); - // // Add the `'rx` lifetime to the generics - // // We also need a separate variable for the generics, but using an - // anonymous // lifetime for a function's return value - // ref_struct.generics.params.insert( - // 0, - // GenericParam::Lifetime(LifetimeDef::new(Lifetime::new("'rx", - // Span::call_site()))), ); - - // match mid_struct.fields { - // syn::Fields::Named(ref mut fields) => { - // for field in fields.named.iter_mut() { - // let orig_ty = &field.ty; - // // Check if this field was registered as one to use nested - // reactivity let wrapper_ty = - // nested_fields_map.get(field.ident.as_ref().unwrap()); - // field.ty = if let Some(wrapper_ty) = wrapper_ty { let - // mid_wrapper_ty = Ident::new( - // &(wrapper_ty.to_string() + "PerseusRxIntermediary"), - // Span::call_site(), ); - // syn::Type::Verbatim(quote!(#mid_wrapper_ty)) - // } else { - // - // syn::Type::Verbatim(quote!(::sycamore::prelude::RcSignal<#orig_ty>)) - // }; - // // Remove any `serde` attributes (Serde can't be used with - // the reactive version) let mut new_attrs = Vec::new(); - // for attr in field.attrs.iter() { - // if !(attr.path.segments.len() == 1 - // && attr.path.segments.first().unwrap().ident == - // "serde") { - // new_attrs.push(attr.clone()); - // } - // } - // field.attrs = new_attrs; - // } - // } - // syn::Fields::Unnamed(_) => return syn::Error::new_spanned( - // mid_struct, - // "tuple structs can't be made reactive with this macro (try using - // named fields instead)", ) - // .to_compile_error(), - // // We may well need a unit struct for templates that use global state - // but don't have proper // state of their own We don't need to - // modify any fields syn::Fields::Unit => (), - // }; - // match ref_struct.fields { - // syn::Fields::Named(ref mut fields) => { - // for field in fields.named.iter_mut() { - // let orig_ty = &field.ty; - // // Check if this field was registered as one to use nested - // reactivity let wrapper_ty = - // nested_fields_map.get(field.ident.as_ref().unwrap()); - // field.ty = if let Some(wrapper_ty) = wrapper_ty { // - // If we don't make this a reference, nested properties have to be cloned - // (not // nice for ergonomics) TODO Check back on this, - // could bite // back! - // syn::Type::Verbatim(quote!(&'rx #wrapper_ty<'rx>)) - // } else { - // // This is the only difference from the intermediate - // `struct` (this lifetime is // declared above) - // syn::Type::Verbatim(quote!(&'rx - // ::sycamore::prelude::RcSignal<#orig_ty>)) }; - // // Remove any `serde` attributes (Serde can't be used with - // the reactive version) let mut new_attrs = Vec::new(); - // for attr in field.attrs.iter() { - // if !(attr.path.segments.len() == 1 - // && attr.path.segments.first().unwrap().ident == - // "serde") { - // new_attrs.push(attr.clone()); - // } - // } - // field.attrs = new_attrs; - // } - // } - // syn::Fields::Unnamed(_) => return syn::Error::new_spanned( - // mid_struct, - // "tuple structs can't be made reactive with this macro (try using - // named fields instead)", ) - // .to_compile_error(), - // // We may well need a unit struct for templates that use global state - // but don't have proper // state of their own We don't need to - // modify any fields syn::Fields::Unit => (), - // }; - - // // Create a list of fields for the `.make_rx()` method - // let make_rx_fields = match mid_struct.fields { - // syn::Fields::Named(ref mut fields) => { - // let mut field_assignments = quote!(); - // for field in fields.named.iter_mut() { - // // We know it has an identifier because it's a named field - // let field_name = field.ident.as_ref().unwrap(); - // // Check if this field was registered as one to use nested - // reactivity if - // nested_fields_map.contains_key(field.ident.as_ref().unwrap()) { - // field_assignments.extend(quote! { - // #field_name: self.#field_name.make_rx(), - // }) - // } else { - // field_assignments.extend(quote! { - // #field_name: - // ::sycamore::prelude::create_rc_signal(self.#field_name), - // }); } - // } - // quote! { - // #mid_name { - // #field_assignments - // } - // } - // } - // syn::Fields::Unit => quote!(#mid_name), - // // We filtered out the other types before - // _ => unreachable!(), - // }; - // // Create a list of fields for turning the intermediary `struct` into one - // using // scoped references - // let make_ref_fields = match mid_struct.fields { - // syn::Fields::Named(ref mut fields) => { - // let mut field_assignments = quote!(); - // for field in fields.named.iter_mut() { - // // We know it has an identifier because it's a named field - // let field_name = field.ident.as_ref().unwrap(); - // // Check if this field was registered as one to use nested - // reactivity if - // nested_fields_map.contains_key(field.ident.as_ref().unwrap()) { - // field_assignments.extend(quote! { - // #field_name: ::sycamore::prelude::create_ref(cx, - // self.#field_name.to_ref_struct(cx)), }) - // } else { - // // This will be used in a place in which the `cx` - // variable stores a reactive // scope - // field_assignments.extend(quote! { - // #field_name: ::sycamore::prelude::create_ref(cx, - // self.#field_name), }); - // } - // } - // quote! { - // #ref_name { - // #field_assignments - // } - // } - // } - // syn::Fields::Unit => quote!(#ref_name), - // // We filtered out the other types before - // _ => unreachable!(), - // }; - // let make_unrx_fields = match orig_struct.fields { - // syn::Fields::Named(ref mut fields) => { - // let mut field_assignments = quote!(); - // for field in fields.named.iter_mut() { - // // We know it has an identifier because it's a named field - // let field_name = field.ident.as_ref().unwrap(); - // // Check if this field was registered as one to use nested - // reactivity if - // nested_fields_map.contains_key(field.ident.as_ref().unwrap()) { - // field_assignments.extend(quote! { - // #field_name: self.#field_name.clone().make_unrx(), - // }) - // } else { - // // We can `.clone()` the field because we implement - // `Clone` on both the new and // the original - // `struct`s, meaning all fields must also be `Clone` - // field_assignments.extend(quote! { #field_name: - // (*self.#field_name.get_untracked()).clone(), }); - // } - // } - // quote! { - // #orig_name { - // #field_assignments - // } - // } - // } - // syn::Fields::Unit => quote!(#orig_name), - // // We filtered out the other types before - // _ => unreachable!(), - // }; - - // quote! { - // // We add a Serde derivation because it will always be necessary for - // Perseus on the original `struct`, and it's really difficult and brittle - // to filter it out // #[derive(::serde::Serialize, - // ::serde::Deserialize, ::std::clone::Clone)] // #orig_struct - // impl #generics ::perseus::state::MakeRx for #orig_name #generics { - // type Rx = #mid_name #generics; - // fn make_rx(self) -> #mid_name #generics { - // use ::perseus::state::MakeRx; - // #make_rx_fields - // } - // } - // #[derive(::std::clone::Clone)] - // #mid_struct - // impl #generics ::perseus::state::MakeUnrx for #mid_name #generics { - // type Unrx = #orig_name #generics; - // fn make_unrx(self) -> #orig_name #generics { - // use ::perseus::state::MakeUnrx; - // #make_unrx_fields - // } - // } - // impl #generics ::perseus::state::Freeze for #mid_name #generics { - // fn freeze(&self) -> ::std::string::String { - // use ::perseus::state::MakeUnrx; - // let unrx = #make_unrx_fields; - // // TODO Is this `.unwrap()` safe? - // ::serde_json::to_string(&unrx).unwrap() - // } - // } - // // TODO Generics - // impl ::perseus::state::MakeRxRef for #mid_name { - // type RxRef<'a> = #ref_name<'a>; - // fn to_ref_struct<'a>(self, cx: ::sycamore::prelude::Scope<'a>) -> - // #ref_name<'a> { #make_ref_fields - // } - // } - // #[derive(::std::clone::Clone)] - // #ref_struct - // impl<'a> ::perseus::state::RxRef for #ref_name<'a> { - // type RxNonRef = #mid_name; - // } - // } } diff --git a/packages/perseus-warp/Cargo.toml b/packages/perseus-warp/Cargo.toml index 13081b45c4..0c7db6ac77 100644 --- a/packages/perseus-warp/Cargo.toml +++ b/packages/perseus-warp/Cargo.toml @@ -15,15 +15,7 @@ categories = ["wasm", "web-programming::http-server", "development-tools", "asyn [dependencies] perseus = { path = "../perseus", version = "0.4.0-beta.11" } -tokio = { version = "1", features = [ "rt-multi-thread" ] } warp = { package = "warp-fix-171", version = "0.3" } # 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.8.1", features = ["ssr"] } [features] # Enables the default server configuration, which provides a convenience function if you're not adding any extra routes diff --git a/packages/perseus-warp/src/conv_req.rs b/packages/perseus-warp/src/conv_req.rs deleted file mode 100644 index b96a51e831..0000000000 --- a/packages/perseus-warp/src/conv_req.rs +++ /dev/null @@ -1,31 +0,0 @@ -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<Extract = (http::Request<()>,), 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::<http::Request<()>, Rejection>(request) - }) -} diff --git a/packages/perseus-warp/src/dflt_server.rs b/packages/perseus-warp/src/dflt_server.rs deleted file mode 100644 index 70da74c082..0000000000 --- a/packages/perseus-warp/src/dflt_server.rs +++ /dev/null @@ -1,17 +0,0 @@ -use crate::perseus_routes; -use perseus::{i18n::TranslationsManager, server::ServerProps, stores::MutableStore}; -use std::net::SocketAddr; - -/// Creates and starts the default Perseus server with Warp. This should be run -/// in a `main` function annotated with `#[tokio::main]` (which requires the -/// `macros` and `rt-multi-thread` features on the `tokio` dependency). -pub async fn dflt_server<M: MutableStore + 'static, T: TranslationsManager + 'static>( - props: ServerProps<M, T>, - (host, port): (String, u16), -) { - let addr: SocketAddr = format!("{}:{}", host, port) - .parse() - .expect("Invalid address provided to bind to."); - let routes = perseus_routes(props).await; - warp::serve(routes).run(addr).await; -} diff --git a/packages/perseus-warp/src/initial_load.rs b/packages/perseus-warp/src/initial_load.rs deleted file mode 100644 index 1e98633ddc..0000000000 --- a/packages/perseus-warp/src/initial_load.rs +++ /dev/null @@ -1,162 +0,0 @@ -use fmterr::fmt_err; -use perseus::{ - errors::{err_to_status_code, ServerError}, - i18n::{TranslationsManager, Translator}, - router::{match_route_atomic, RouteInfoAtomic, RouteVerdictAtomic}, - server::{ - build_error_page, get_page_for_template, get_path_slice, GetPageProps, HtmlShell, - ServerOptions, - }, - state::GlobalStateCreator, - stores::{ImmutableStore, MutableStore}, - template::TemplateState, - utils::get_path_prefix_server, - 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<Rc<Translator>>, - error_pages: &ErrorPages<SsrNode>, - html_shell: &HtmlShell, -) -> Response<String> { - let html = build_error_page(url, status, err, translator, error_pages, html_shell); - 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<M: MutableStore, T: TranslationsManager>( - path: FullPath, - req: perseus::http::Request<()>, - opts: Arc<ServerOptions>, - html_shell: Arc<HtmlShell>, - render_cfg: Arc<HashMap<String, String>>, - immutable_store: Arc<ImmutableStore>, - mutable_store: Arc<M>, - translations_manager: Arc<T>, - global_state: Arc<TemplateState>, - gsc: Arc<GlobalStateCreator>, -) -> Response<String> { - let error_pages = &opts.error_pages; - let path = match urlencoding::decode(path.as_str()) { - Ok(path) => path.to_string(), - Err(err) => { - return return_error_page( - path.as_str(), - 400, - &fmt_err(&ServerError::UrlDecodeFailed { source: err }), - None, - error_pages, - html_shell.as_ref(), - ) - } - }; - let path = path.as_str(); - let templates = &opts.templates_map; - 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()); - }; - - // 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( - GetPageProps::<M, T> { - raw_path: &path, - locale: &locale, - was_incremental_match, - req, - global_state: &global_state, - immutable_store: &immutable_store, - mutable_store: &mutable_store, - translations_manager: &translations_manager, - global_state_creator: &gsc, - }, - template, - true, - ) - .await; - let (page_data, global_state) = 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)); - } - }; - // Get the translations to interpolate into the page - let translations = translations_manager - .get_translations_str_for_locale(locale) - .await; - let translations = match translations { - Ok(translations) => translations, - // We know for sure that this locale is supported, so there's been an internal - // server error if it can't be found - Err(err) => { - return html_err(500, &fmt_err(&err)); - } - }; - - let final_html = html_shell - .as_ref() - .clone() - .page_data(&page_data, &global_state, &translations) - .to_string(); - - 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(TemplateState::from_value(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(302) // NOTE: Changed this from 200 (I think that was a mistake...) - .body( - html_shell - .as_ref() - .clone() - .locale_redirection_fallback( - // We'll redirect the user to the default locale - &format!( - "{}/{}/{}", - get_path_prefix_server(), - opts.locales.default, - path - ), - ) - .to_string(), - ) - .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 index d990019218..2eb0509eb9 100644 --- a/packages/perseus-warp/src/lib.rs +++ b/packages/perseus-warp/src/lib.rs @@ -7,17 +7,190 @@ documentation, and this should mostly be used as a secondary reference source. Y */ #![deny(missing_docs)] +#![deny(missing_debug_implementations)] -mod conv_req; -#[cfg(feature = "dflt-server")] -mod dflt_server; -mod initial_load; -mod page_data; -mod perseus_routes; +// Serving files from a map is *really* convoluted mod static_content; -mod translations; +use crate::static_content::{serve_file, static_aliases_filter}; + +use perseus::http; +use perseus::turbine::ApiResponse as PerseusApiResponse; +use perseus::{ + i18n::TranslationsManager, + path::*, + server::ServerOptions, + stores::MutableStore, + turbine::{SubsequentLoadQueryParams, Turbine}, + Request, +}; +use std::{path::PathBuf, sync::Arc}; +use warp::{ + path::{FullPath, Tail}, + reply::Response, + Filter, Rejection, Reply, +}; + +// ----- Request conversion implementation ----- + +/// 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<Extract = (http::Request<()>,), 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::<http::Request<()>, Rejection>(request) + }) +} + +// ----- Newtype wrapper for response implementation ----- + +#[derive(Debug)] +struct ApiResponse(PerseusApiResponse); +impl From<PerseusApiResponse> for ApiResponse { + fn from(val: PerseusApiResponse) -> Self { + Self(val) + } +} +impl Reply for ApiResponse { + fn into_response(self) -> Response { + let mut response = Response::new(self.0.body.into()); + *response.status_mut() = self.0.status; + *response.headers_mut() = self.0.headers; + response + } +} -pub use crate::perseus_routes::perseus_routes; +// ----- Integration code ----- + +/// 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<M: MutableStore + 'static, T: TranslationsManager + 'static>( + turbine: &'static Turbine<M, T>, + opts: ServerOptions, +) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone { + // --- File handlers --- + 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())); + let wasm_js_bundle = warp::path!(".perseus" / "bundle.wasm.js") + .and(warp::path::end()) + .and(warp::fs::file(opts.wasm_js_bundle.clone())); + let snippets = warp::path!(".perseus" / "snippets" / ..).and(warp::fs::dir(opts.snippets)); + + // --- Translation and subsequent load handlers --- + let translations = + warp::path!(".perseus" / "translations" / String).then(move |locale: String| async move { + ApiResponse(turbine.get_translations(&locale).await) + }); + let page_data = warp::path!(".perseus" / "page" / String / ..) + .and(warp::path::tail()) + .and(warp::query::<SubsequentLoadQueryParams>()) + .and(get_http_req()) + .then( + move |locale: String, + path: Tail, // This is the path after the locale that was sent + SubsequentLoadQueryParams { + entity_name, + was_incremental_match, + }: SubsequentLoadQueryParams, + http_req: Request| async move { + ApiResponse( + turbine + .get_subsequent_load( + PathWithoutLocale(path.as_str().to_string()), + locale, + entity_name, + was_incremental_match, + http_req, + ) + .await, + ) + }, + ); + + // --- Static directory and alias handlers --- + let static_dir_path = Arc::new(turbine.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<PathBuf>| async move { + if static_dir_path.exists() { + 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(turbine.static_dir.clone())); + let static_aliases = warp::any() + .and(static_aliases_filter(turbine.static_aliases.clone())) + .and_then(serve_file); + + // --- Initial load handler --- + let initial_loads = warp::any() + .and(warp::path::full()) + .and(get_http_req()) + .then(move |path: FullPath, http_req: Request| async move { + ApiResponse( + turbine + .get_initial_load(PathMaybeWithLocale(path.as_str().to_string()), http_req) + .await, + ) + }); + + // 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(wasm_js_bundle) + .or(snippets) + .or(static_dir) + .or(static_aliases) + .or(translations) + .or(page_data) + .or(initial_loads) +} + +// ----- Default server ----- + +/// Creates and starts the default Perseus server with Warp. This should be run +/// in a `main` function annotated with `#[tokio::main]` (which requires the +/// `macros` and `rt-multi-thread` features on the `tokio` dependency). #[cfg(feature = "dflt-server")] -pub use dflt_server::dflt_server; -pub use perseus::server::ServerOptions; +pub async fn dflt_server<M: MutableStore + 'static, T: TranslationsManager + 'static>( + turbine: &'static Turbine<M, T>, + opts: ServerOptions, + (host, port): (String, u16), +) { + use std::net::SocketAddr; + + let addr: SocketAddr = format!("{}:{}", host, port) + .parse() + .expect("Invalid address provided to bind to."); + let routes = perseus_routes(turbine, opts).await; + warp::serve(routes).run(addr).await; +} diff --git a/packages/perseus-warp/src/page_data.rs b/packages/perseus-warp/src/page_data.rs deleted file mode 100644 index 2f863612a3..0000000000 --- a/packages/perseus-warp/src/page_data.rs +++ /dev/null @@ -1,104 +0,0 @@ -use fmterr::fmt_err; -use perseus::{ - errors::err_to_status_code, - i18n::TranslationsManager, - internal::PageDataPartial, - server::{get_page_for_template, GetPageProps, ServerOptions}, - state::GlobalStateCreator, - stores::{ImmutableStore, MutableStore}, - template::TemplateState, -}; -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 differently, 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<M: MutableStore, T: TranslationsManager>( - 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<ServerOptions>, - immutable_store: Arc<ImmutableStore>, - mutable_store: Arc<M>, - translations_manager: Arc<T>, - global_state: Arc<TemplateState>, - gsc: Arc<GlobalStateCreator>, -) -> Response<String> { - 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( - GetPageProps::<M, T> { - raw_path: path, - locale: &locale, - was_incremental_match, - req: http_req, - global_state: &global_state, - immutable_store: &immutable_store, - mutable_store: &mutable_store, - translations_manager: &translations_manager, - global_state_creator: &gsc, - }, - template, - false, - ) - .await; - match page_data { - Ok((page_data, _)) => { - let partial_page_data = PageDataPartial { - state: page_data.state.clone(), - head: page_data.head, - }; - 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(TemplateState::from_value(page_data.state)) { - http_res = http_res.header(key.unwrap(), val); - } - - let page_data_str = serde_json::to_string(&partial_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 deleted file mode 100644 index 30723a76f9..0000000000 --- a/packages/perseus-warp/src/perseus_routes.rs +++ /dev/null @@ -1,132 +0,0 @@ -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::server::{get_render_cfg, ServerProps}; -use perseus::state::get_built_global_state; -use perseus::{i18n::TranslationsManager, stores::MutableStore}; -use std::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<M: MutableStore + 'static, T: TranslationsManager + 'static>( - ServerProps { - opts, - immutable_store, - mutable_store, - translations_manager, - global_state_creator, - }: ServerProps<M, T>, -) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone { - let render_cfg = get_render_cfg(&immutable_store) - .await - .expect("Couldn't get render configuration!"); - let index_with_render_cfg = opts.html_shell.clone(); - // Generate the global state - let global_state = get_built_global_state(&immutable_store) - .await - .expect("couldn't get pre-built global state or placeholder (the app's build artifacts have almost certainly been corrupted)"); - - // 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())); - let wasm_js_bundle = warp::path!(".perseus" / "bundle.wasm.js") - .and(warp::path::end()) - .and(warp::fs::file(opts.wasm_js_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<Option<String>>| 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()); - let global_state = Arc::new(global_state); - let global_state = warp::any().map(move || global_state.clone()); - let gsc = warp::any().map(move || global_state_creator.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::<PageDataReq>()) - .and(get_http_req()) - .and(opts.clone()) - .and(immutable_store.clone()) - .and(mutable_store.clone()) - .and(translations_manager.clone()) - .and(global_state.clone()) - .and(gsc.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) - .and(global_state) - .and(gsc) - .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(wasm_js_bundle) - .or(snippets) - .or(static_dir) - .or(static_aliases) - .or(translations) - .or(page_data) - .or(initial_loads) -} diff --git a/packages/perseus-warp/src/translations.rs b/packages/perseus-warp/src/translations.rs deleted file mode 100644 index b1e8a21ff5..0000000000 --- a/packages/perseus-warp/src/translations.rs +++ /dev/null @@ -1,30 +0,0 @@ -use fmterr::fmt_err; -use perseus::{i18n::TranslationsManager, server::ServerOptions}; -use std::sync::Arc; -use warp::http::Response; - -pub async fn translations_handler<T: TranslationsManager>( - locale: String, - opts: Arc<ServerOptions>, - translations_manager: Arc<T>, -) -> Response<String> { - // 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 68d026c10e..0463ac8905 100644 --- a/packages/perseus/Cargo.toml +++ b/packages/perseus/Cargo.toml @@ -14,7 +14,7 @@ categories = ["wasm", "web-programming", "development-tools", "asynchronous", "g # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -sycamore = { version = "^0.8.1", features = [ "ssr" ] } +sycamore = { version = "^0.8.1", features = [ "ssr", "suspense" ] } sycamore-router = "0.8" sycamore-futures = "0.8" perseus-macro = { path = "../perseus-macro", version = "0.4.0-beta.11", optional = true } @@ -84,3 +84,5 @@ wasm2js = [] live-reload = [ "js-sys", "web-sys/WebSocket", "web-sys/MessageEvent", "web-sys/ErrorEvent", "web-sys/BinaryType", "web-sys/Location" ] # Enables hot state reloading, whereby your entire app's state can be frozen and thawed automatically every time you change code in your app hsr = [ "live-reload", "idb-freezing" ] +# Enables reactive versions of common Rust collections, like `Vec<T>` and `HashMap<K, V>`. (Note that `RxResult` is always present, as it's needed for suspended state.) +rx-collections = [] diff --git a/packages/perseus/src/build.rs b/packages/perseus/src/build.rs deleted file mode 100644 index cb85dd6bfe..0000000000 --- a/packages/perseus/src/build.rs +++ /dev/null @@ -1,480 +0,0 @@ -// This binary builds all the templates with SSG - -use crate::errors::*; -use crate::i18n::{Locales, TranslationsManager}; -use crate::state::GlobalStateCreator; -use crate::stores::{ImmutableStore, MutableStore}; -use crate::template::ArcTemplateMap; -use crate::template::{BuildPaths, StateGeneratorInfo, Template, TemplateState}; -use crate::translator::Translator; -use crate::utils::minify; -use futures::future::try_join_all; -use std::collections::HashMap; -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 of the extracted -/// render options for this template (needed at request time), a list of pages -/// that it explicitly generated, and a boolean as to whether or not it only -/// generated a single page to occupy the template's root path (`true` unless -/// using using build-time path generation). -pub async fn build_template( - template: &Template<SsrNode>, - translator: &Translator, - (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), - global_state: &TemplateState, - exporting: bool, -) -> Result<(Vec<String>, bool), ServerError> { - let mut single_page = false; - let template_path = template.get_path(); - - // If we're exporting, ensure that all the template's strategies are export-safe - // (not requiring a server) - if exporting - && (template.revalidates() || - template.uses_incremental() || - template.uses_request_state() || - // We check amalgamation as well because it involves request state, even if that wasn't provided - template.can_amalgamate_states()) - { - return Err(ExportError::TemplateNotExportable { - template_name: template_path.clone(), - } - .into()); - } - - // Handle static path generation - // Because we iterate over the paths, we need a base path if we're not - // generating custom ones (that'll be overridden if needed) - let (paths, build_extra) = match template.uses_build_paths() { - true => { - let BuildPaths { paths, extra } = template.get_build_paths().await?; - // Trim away any trailing `/`s so we don't insert them into the render config - // That makes rendering an index page from build paths impossible (see #39) - let paths = paths - .iter() - .map(|p| match p.strip_suffix('/') { - Some(stripped) => stripped.to_string(), - None => p.to_string(), - }) - .collect(); - (paths, extra) - } - false => { - single_page = true; - (vec![String::new()], TemplateState::empty()) - } - }; - - // Write the extra build state information to a file now so it can be accessed - // by request state handlers and the like down the line - immutable_store - .write( - &format!("static/{}.extra.json", template_path), - &build_extra.state.to_string(), - ) - .await?; - - // Iterate through the paths to generate initial states if needed - // Note that build paths pages on incrementally generable pages will use the - // immutable store - let mut futs = Vec::new(); - for path in paths.iter() { - let fut = gen_state_for_path( - path, - template, - translator, - (immutable_store, mutable_store), - global_state, - &build_extra, - ); - futs.push(fut); - } - try_join_all(futs).await?; - - Ok((paths, single_page)) -} - -/// Generates state for a single page within a template. This is broken out into -/// a separate function for concurrency. -async fn gen_state_for_path( - path: &str, - template: &Template<SsrNode>, - translator: &Translator, - (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), - global_state: &TemplateState, - build_extra: &TemplateState, -) -> Result<(), ServerError> { - let template_path = template.get_path(); - // If needed, we'll construct a full path that's URL encoded so we can easily - // save it as a file - let full_path_without_locale = match template.uses_build_paths() { - true => format!("{}/{}", &template_path, path), - // We don't want to concatenate the name twice if we don't have to - false => template_path.clone(), - }; - // Strip leading/trailing `/`s for the reasons described above - // Leading is to handle index pages with build paths - let full_path_without_locale = match full_path_without_locale.strip_suffix('/') { - Some(stripped) => stripped.to_string(), - None => full_path_without_locale, - }; - let full_path_without_locale = match full_path_without_locale.strip_prefix('/') { - Some(stripped) => stripped.to_string(), - None => full_path_without_locale, - }; - // Add the current locale to the front of that and encode it as a URL so we can - // store a flat series of files BUG: insanely nested paths won't work - // whatsoever if the filename is too long, maybe hash instead? - let full_path_encoded = format!( - "{}-{}", - translator.get_locale(), - urlencoding::encode(&full_path_without_locale) - ); - // And we'll need the full path with the locale for the `PageProps` - // If it's `xx-XX`, we should just have it without the locale (this may be - // interacted with by users) - let locale = translator.get_locale(); - let full_path_with_locale = match locale.as_str() { - "xx-XX" => full_path_without_locale.clone(), - locale => format!("{}/{}", locale, &full_path_without_locale), - }; - - let build_info = StateGeneratorInfo { - path: full_path_without_locale.clone(), - locale: translator.get_locale(), - extra: build_extra.clone(), - }; - - // Handle static initial state generation - // We'll only write a static state if one is explicitly generated - // If the template revalidates, use a mutable store, otherwise use an immutable - // one - if template.uses_build_state() && template.revalidates() { - // We pass in the path to get a state (including the template path for - // consistency with the incremental logic) - let initial_state = template.get_build_state(build_info).await?; - // Write that initial state to a static JSON file - mutable_store - .write( - &format!("static/{}.json", full_path_encoded), - &initial_state.state.to_string(), - ) - .await?; - // Prerender the template using that state - let prerendered = sycamore::render_to_string(|cx| { - template.render_for_template_server( - full_path_with_locale.clone(), - initial_state.clone(), - global_state.clone(), - cx, - translator, - ) - }); - minify(&prerendered, true)?; - // Write that prerendered HTML to a static file - mutable_store - .write(&format!("static/{}.html", full_path_encoded), &prerendered) - .await?; - // Prerender the document `<head>` 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(initial_state, global_state.clone(), translator); - minify(&head_str, true)?; - mutable_store - .write( - &format!("static/{}.head.html", full_path_encoded), - &head_str, - ) - .await?; - } else if template.uses_build_state() { - // We pass in the path to get a state (including the template path for - // consistency with the incremental logic) - let initial_state = template.get_build_state(build_info).await?; - // Write that initial state to a static JSON file - immutable_store - .write( - &format!("static/{}.json", full_path_encoded), - &initial_state.state.to_string(), - ) - .await?; - // Prerender the template using that state - let prerendered = sycamore::render_to_string(|cx| { - template.render_for_template_server( - full_path_with_locale.clone(), - initial_state.clone(), - global_state.clone(), - cx, - translator, - ) - }); - minify(&prerendered, true)?; - // Write that prerendered HTML to a static file - immutable_store - .write(&format!("static/{}.html", full_path_encoded), &prerendered) - .await?; - // Prerender the document `<head>` 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(initial_state, global_state.clone(), translator); - immutable_store - .write( - &format!("static/{}.head.html", full_path_encoded), - &head_str, - ) - .await?; - } - - // 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() { - let datetime_to_revalidate = template - .get_revalidate_interval() - .unwrap() - .compute_timestamp(); - // Write that to a static file, we'll update it every time we revalidate - // Note that this runs for every path generated, so it's fully usable with ISR - // Yes, there's a different revalidation schedule for each locale, but that - // means we don't have to rebuild every locale simultaneously - mutable_store - .write( - &format!("static/{}.revld.txt", full_path_encoded), - &datetime_to_revalidate.to_string(), - ) - .await?; - } - - // Note that SSR has already been handled by checking for - // `.uses_request_state()` above, we don't need to do any rendering here - // If a template only uses SSR, it won't get prerendered at build time - // whatsoever - - // 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(|cx| { - template.render_for_template_server( - full_path_with_locale, - TemplateState::empty(), - global_state.clone(), - cx, - translator, - ) - }); - let head_str = - template.render_head_str(TemplateState::empty(), global_state.clone(), translator); - // Write that prerendered HTML to a static file - immutable_store - .write(&format!("static/{}.html", full_path_encoded), &prerendered) - .await?; - immutable_store - .write( - &format!("static/{}.head.html", full_path_encoded), - &head_str, - ) - .await?; - } - - Ok(()) -} - -/// Builds all pages within a template and compiles its component of the render -/// configuration. -pub async fn build_template_and_get_cfg( - template: &Template<SsrNode>, - translator: &Translator, - (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), - global_state: &TemplateState, - exporting: bool, -) -> Result<HashMap<String, String>, ServerError> { - let mut render_cfg = HashMap::new(); - let template_root_path = template.get_path(); - let is_incremental = template.uses_incremental(); - - let (pages, single_page) = build_template( - template, - translator, - (immutable_store, mutable_store), - global_state, - exporting, - ) - .await?; - // If the template represents a single page itself, we don't need any - // concatenation - if single_page { - render_cfg.insert(template_root_path.clone(), template_root_path.clone()); - } else { - // Add each page that the template explicitly generated (ignoring ISR for now) - for page in pages { - let path = format!("{}/{}", &template_root_path, &page); - // Remove any leading/trailing `/`s for the reasons described above - let path = match path.strip_suffix('/') { - Some(stripped) => stripped.to_string(), - None => path, - }; - let path = match path.strip_prefix('/') { - Some(stripped) => stripped.to_string(), - None => path, - }; - render_cfg.insert(path, template_root_path.clone()); - } - // Now if the page uses ISR, add an explicit `/*` in there after the template - // root path Incremental rendering requires build-time path generation - if is_incremental { - render_cfg.insert( - format!("{}/*", &template_root_path), - template_root_path.clone(), - ); - } - } - - Ok(render_cfg) -} - -/// Runs the build process of building many different templates for a single -/// locale. If you're not using i18n, provide a `Translator::empty()` -/// 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: &ArcTemplateMap<SsrNode>, - translator: &Translator, - (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), - global_state: &TemplateState, - exporting: bool, -) -> Result<(), ServerError> { - // The render configuration stores a list of pages to the root paths of their - // templates - let mut render_cfg = HashMap::new(); - // Create each of the templates - let mut futs = Vec::new(); - for template in templates.values() { - futs.push(build_template_and_get_cfg( - template, - translator, - (immutable_store, mutable_store), - global_state, - exporting, - )); - } - let template_cfgs = try_join_all(futs).await?; - for template_cfg in template_cfgs { - render_cfg.extend(template_cfg.into_iter()) - } - - immutable_store - .write( - "render_conf.json", - &serde_json::to_string(&render_cfg).unwrap(), - ) - .await?; - - Ok(()) -} - -/// Gets a translator and builds templates for a single locale. -/// -/// This will also build the global state for this locale. -pub async fn build_templates_and_translator_for_locale( - templates: &ArcTemplateMap<SsrNode>, - locale: String, - (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), - translations_manager: &impl TranslationsManager, - gsc: &GlobalStateCreator, - exporting: bool, -) -> Result<(), ServerError> { - let translator = translations_manager - .get_translator_for_locale(locale.to_string()) - .await?; - - let global_state = if gsc.uses_build_state() { - // Generate the global state and write it to a file - let global_state = gsc.get_build_state(locale).await?; - immutable_store - .write("static/global_state.json", &global_state.state.to_string()) - .await?; - global_state - } else { - // If there's no build-time handler, we'll give an empty state. This will - // be very unexpected if the user is generating at request-time, since all - // the pages they have at build-time will be unable to access the global state. - // We could either completely disable build-time rendering when there's - // request-time global state generation, or we could give the user smart - // errors and let them manage this problem themselves by gating their - // usage of global state at build-time, since `.try_get_global_state()` - // will give a clear `Ok(None)` at build-time. For speed, the latter - // approach has been chosen. - // - // This is one of the biggest 'gotchas' in Perseus, and is clearly documented! - TemplateState::empty() - }; - - if exporting && (gsc.uses_request_state() || gsc.can_amalgamate_states()) { - return Err(ExportError::GlobalStateNotExportable.into()); - } - - build_templates_for_locale( - templates, - &translator, - (immutable_store, mutable_store), - &global_state, - exporting, - ) - .await?; - - Ok(()) -} - -/// The properties needed to build an app. -#[derive(Debug)] -pub struct BuildProps<'a, M: MutableStore, T: TranslationsManager> { - /// All the templates in the app. - pub templates: &'a ArcTemplateMap<SsrNode>, - /// The app's locales data. - pub locales: &'a Locales, - /// An immutable store. - pub immutable_store: &'a ImmutableStore, - /// A mutable store. - pub mutable_store: &'a M, - /// A translations manager. - pub translations_manager: &'a T, - /// The global state creator. - pub global_state_creator: &'a GlobalStateCreator, - /// Whether or not we're exporting after this build (changes behavior - /// slightly). - pub exporting: bool, -} - -/// Runs the build process of building many templates for the given locales -/// data, building directly for all supported locales. This is fine because of -/// how ridiculously fast builds are. -pub async fn build_app<M: MutableStore, T: TranslationsManager>( - BuildProps { - templates, - locales, - immutable_store, - mutable_store, - translations_manager, - global_state_creator, - exporting, - }: BuildProps<'_, M, T>, -) -> Result<(), ServerError> { - let locales = locales.get_all(); - let mut futs = Vec::new(); - - for locale in locales { - futs.push(build_templates_and_translator_for_locale( - templates, - locale.to_string(), - (immutable_store, mutable_store), - translations_manager, - global_state_creator, - exporting, - )); - } - // Build all locales in parallel - try_join_all(futs).await?; - - Ok(()) -} diff --git a/packages/perseus/src/client.rs b/packages/perseus/src/client.rs index 6a0db49767..9ae40613db 100644 --- a/packages/perseus/src/client.rs +++ b/packages/perseus/src/client.rs @@ -1,13 +1,9 @@ -use crate::errors::PluginError; -use crate::{ - checkpoint, - plugins::PluginAction, - router::{perseus_router, PerseusRouterProps}, - template::TemplateNodeType, -}; -use crate::{i18n::TranslationsManager, stores::MutableStore, PerseusAppBase}; -use fmterr::fmt_err; -use std::collections::HashMap; +use crate::reactor::Reactor; +use crate::{checkpoint, plugins::PluginAction, template::BrowserNodeType}; +use crate::{i18n::TranslationsManager, init::PerseusAppBase, stores::MutableStore}; +use sycamore::prelude::create_scope; +#[cfg(feature = "hydrate")] +use sycamore::utils::hydrate::with_hydration_context; use wasm_bindgen::JsValue; /// The entrypoint into the app itself. This will be compiled to Wasm and @@ -20,111 +16,108 @@ use wasm_bindgen::JsValue; /// /// For consistency with `run_dflt_engine`, this takes a function that returns /// the `PerseusApp`. +/// +/// Note that, by the time this, or any of our code, is executing, the user can +/// already see something due to engine-side rendering. +/// +/// This function performs all error handling internally, and will do its level +/// best not to fail, including through setting panic handlers. pub fn run_client<M: MutableStore, T: TranslationsManager>( - app: impl Fn() -> PerseusAppBase<TemplateNodeType, M, T>, -) -> Result<(), JsValue> { + app: impl Fn() -> PerseusAppBase<BrowserNodeType, M, T>, +) { let mut app = app(); - let panic_handler = app.take_panic_handler(); + // The latter of these is a clone of the handler used for other errors + let (general_panic_handler, view_panic_handler) = app.take_panic_handlers(); checkpoint("begin"); - // Handle panics (this works for unwinds and aborts) + // Handle panics (this works for both unwinds and aborts) std::panic::set_hook(Box::new(move |panic_info| { - // Print to the console in development + // Print to the console in development (details are withheld in production, + // they'll just get 'unreachable executed') #[cfg(debug_assertions)] console_error_panic_hook::hook(panic_info); - // If the user wants a little warning dialogue, create that - if let Some(panic_handler) = &panic_handler { + + // In case anything after this fails (which, since we're calling out to + // view rendering and user code, is reasonably likely), put out a console + // message to try to explain things (differentiated for end users) + #[cfg(debug_assertions)] + crate::web_log!("[CRITICAL ERROR]: Perseus has panicked! An error message has hopefully been displayed on your screen explaining this; if not, then something has gone terribly wrong, and, unless your code is panicking, you should report this as a bug. (If you're seeing this as an end user, please report it to the website administrator.)"); + #[cfg(not(debug_assertions))] + crate::web_log!("[CRITICAL ERROR]: Perseus has panicked! An error message has hopefully been displayed on your screen explaining this; if not, then reloading the page might help."); + + // Run the user's arbitrary panic handler + if let Some(panic_handler) = &general_panic_handler { panic_handler(panic_info); } - })); - - let res = client_core(app); - if let Err(err) = res { - // This will go to the panic handler we defined above - // Unfortunately, at this stage, we really can't do anything else - panic!("plugin error: {}", fmt_err(&err)); - } - Ok(()) -} + // Try to render an error page + Reactor::handle_panic(panic_info, view_panic_handler.clone()); -/// This executes the actual underlying browser-side logic, including -/// instantiating the user's app. This is broken out due to plugin fallibility. -fn client_core<M: MutableStore, T: TranslationsManager>( - app: PerseusAppBase<TemplateNodeType, M, T>, -) -> Result<(), PluginError> { - let plugins = app.get_plugins(); + // There is **not** a plugin opportunity here because that would require + // cloning the plugins into here. Any of that can be managed by the + // arbitrary user-given panic handler. Please appreciate how + // unreasonably difficult it is to get variables into a panic + // hook. + })); - plugins - .functional_actions - .client_actions - .start - .run((), plugins.get_plugin_data())?; - checkpoint("initial_plugins_complete"); + let plugins = app.plugins.clone(); + let error_views = app.error_views.clone(); - // Get the root we'll be injecting the router into - let root = web_sys::window() - .unwrap() - .document() - .unwrap() - .query_selector(&format!("#{}", app.get_root()?)) - .unwrap() - .unwrap(); + // This variable acts as a signal to determine whether or not there was a + // show-stopping failure that should trigger root scope disposal + // (terminating Perseus and rendering the app inoperable) + let mut running = true; + // === IF THIS DISPOSER IS CALLED, PERSEUS WILL TERMINATE! === + let app_disposer = create_scope(|cx| { + let core = move || { + // Create the reactor + match Reactor::try_from(app) { + Ok(reactor) => { + // We're away! + reactor.add_self_to_cx(cx); + let reactor = Reactor::from_cx(cx); + reactor.start(cx) + } + Err(err) => { + // We don't have a reactor, so render a critical popup error, hoping the user + // can see something prerendered that makes sense (this + // displays and everything) + Reactor::handle_critical_error(cx, err, &error_views); + // We can't do anything without a reactor + false + } + } + }; - // Set up the properties we'll pass to the router - let router_props = PerseusRouterProps { - locales: app.get_locales()?, - error_pages: app.get_error_pages(), - templates: app.get_templates_map()?, - render_cfg: get_render_cfg().expect("render configuration invalid or not injected"), - pss_max_size: app.get_pss_max_size(), - }; + // If we're using hydration, everything has to be done inside a hydration + // context (because of all the custom view handling) + #[cfg(feature = "hydrate")] + { + running = with_hydration_context(|| core()); + } + #[cfg(not(feature = "hydrate"))] + { + running = core(); + } + }); - // At this point, the user can already see something from the server-side - // rendering, so we now have time to figure out exactly what to render. - // Having done that, we can render/hydrate, depending on the feature flags. - // All that work is done inside the router. + // If we failed, terminate + if !running { + // SAFETY We're outside the app's scope. + unsafe { app_disposer.dispose() } + // This is one of the best places in Perseus for crash analytics + plugins + .functional_actions + .client_actions + .crash + .run((), plugins.get_plugin_data()) + .expect("plugin action on crash failed"); - // This top-level context is what we use for everything, allowing page state to - // be registered and stored for the lifetime of the app - // Note: root lifetime creation occurs here - #[cfg(feature = "hydrate")] - sycamore::hydrate_to(move |cx| perseus_router(cx, router_props), &root); - #[cfg(not(feature = "hydrate"))] - { - // We have to delete the existing content before we can render the new stuff - // (which should be the same) - root.set_inner_html(""); - sycamore::render_to(move |cx| perseus_router(cx, router_props), &root); + // Goodbye, dear friends. } - - Ok(()) } /// A convenience type wrapper for the type returned by nearly all client-side /// entrypoints. pub type ClientReturn = Result<(), JsValue>; - -/// Gets the render configuration from the JS global variable -/// `__PERSEUS_RENDER_CFG`, which should be inlined by the server. This will -/// return `None` on any error (not found, serialization failed, etc.), which -/// should reasonably lead to a `panic!` in the caller. -fn get_render_cfg() -> Option<HashMap<String, String>> { - let val_opt = web_sys::window().unwrap().get("__PERSEUS_RENDER_CFG"); - let js_obj = match val_opt { - Some(js_obj) => js_obj, - None => return None, - }; - // The object should only actually contain the string value that was injected - let cfg_str = match js_obj.as_string() { - Some(cfg_str) => cfg_str, - None => return None, - }; - let render_cfg = match serde_json::from_str::<HashMap<String, String>>(&cfg_str) { - Ok(render_cfg) => render_cfg, - Err(_) => return None, - }; - - Some(render_cfg) -} diff --git a/packages/perseus/src/engine/build.rs b/packages/perseus/src/engine/build.rs deleted file mode 100644 index 13dab724b5..0000000000 --- a/packages/perseus/src/engine/build.rs +++ /dev/null @@ -1,72 +0,0 @@ -use crate::build::{build_app, BuildProps}; -use crate::errors::Error; -use crate::{ - i18n::TranslationsManager, plugins::PluginAction, stores::MutableStore, PerseusAppBase, SsrNode, -}; -use std::rc::Rc; - -/// Builds the app, calling all necessary plugin opportunities. This works -/// solely with the properties provided in the given -/// [`PerseusApp`](crate::PerseusApp), so this is entirely engine-agnostic. -/// -/// Note that this expects to be run in the root of the project. -pub async fn build<M: MutableStore, T: TranslationsManager>( - app: PerseusAppBase<SsrNode, M, T>, -) -> Result<(), Rc<Error>> { - let plugins = app.get_plugins(); - - plugins - .functional_actions - .build_actions - .before_build - .run((), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - - let immutable_store = app - .get_immutable_store() - .map_err(|err| Rc::new(err.into()))?; - let mutable_store = app.get_mutable_store(); - let locales = app.get_locales().map_err(|err| Rc::new(err.into()))?; - let gsc = app.get_global_state_creator(); - - // Build the site for all the common locales (done in parallel) - // All these parameters can be modified by `PerseusApp` and plugins, so there's - // no point in having a plugin opportunity here - let templates_map = app - .get_atomic_templates_map() - .map_err(|err| Rc::new(err.into()))?; - - // We have to get the translations manager last, because it consumes everything - let translations_manager = app.get_translations_manager().await; - - let res = build_app(BuildProps { - templates: &templates_map, - locales: &locales, - immutable_store: &immutable_store, - mutable_store: &mutable_store, - translations_manager: &translations_manager, - global_state_creator: &gsc, - exporting: false, - }) - .await; - if let Err(err) = res { - let err: Rc<Error> = Rc::new(err.into()); - plugins - .functional_actions - .build_actions - .after_failed_build - .run(err.clone(), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - - Err(err) - } else { - plugins - .functional_actions - .build_actions - .after_successful_build - .run((), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - - Ok(()) - } -} diff --git a/packages/perseus/src/engine/dflt_engine.rs b/packages/perseus/src/engine/dflt_engine.rs index 271e68792f..bd4c87b768 100644 --- a/packages/perseus/src/engine/dflt_engine.rs +++ b/packages/perseus/src/engine/dflt_engine.rs @@ -1,13 +1,14 @@ // This file contains functions exclusive to the default engine systems -use super::serve::{get_host_and_port, get_props}; +use super::serve::get_host_and_port; use super::EngineOperation; -use crate::{ - i18n::TranslationsManager, server::ServerProps, stores::MutableStore, PerseusAppBase, SsrNode, -}; +use crate::server::ServerOptions; +use crate::turbine::Turbine; +use crate::{i18n::TranslationsManager, init::PerseusAppBase, stores::MutableStore}; use fmterr::fmt_err; use futures::Future; use std::env; +use sycamore::web::SsrNode; /// A wrapper around `run_dflt_engine` for apps that only use exporting, and so /// don't need to bring in a server integration. This is designed to avoid extra @@ -15,11 +16,11 @@ use std::env; /// `panic!` after building everything. pub async fn run_dflt_engine_export_only<M, T, A>(op: EngineOperation, app: A) -> i32 where - M: MutableStore, - T: TranslationsManager, + M: MutableStore + 'static, + T: TranslationsManager + 'static, A: Fn() -> PerseusAppBase<SsrNode, M, T> + 'static + Send + Sync + Clone, { - let serve_fn = |_, _| async { + let serve_fn = |_, _, _| async { panic!("`run_dflt_engine_export_only` cannot run a server; you should use `run_dflt_engine` instead and import a server integration (e.g. `perseus-warp`)") }; run_dflt_engine(op, app, serve_fn).await @@ -47,23 +48,32 @@ where pub async fn run_dflt_engine<M, T, F, A>( op: EngineOperation, app: A, - serve_fn: impl Fn(ServerProps<M, T>, (String, u16)) -> F, + serve_fn: impl Fn(&'static Turbine<M, T>, ServerOptions, (String, u16)) -> F, ) -> i32 where - M: MutableStore, - T: TranslationsManager, + M: MutableStore + 'static, + T: TranslationsManager + 'static, F: Future<Output = ()>, A: Fn() -> PerseusAppBase<SsrNode, M, T> + 'static + Send + Sync + Clone, { + // The turbine is the core of Perseus' state generation system + let mut turbine = match Turbine::try_from(app()) { + Ok(turbine) => turbine, + Err(err) => { + eprintln!("{}", fmt_err(&err)); + return 1; + } + }; + match op { - EngineOperation::Build => match super::engine_build(app()).await { + EngineOperation::Build => match turbine.build().await { Ok(_) => 0, Err(err) => { eprintln!("{}", fmt_err(&*err)); 1 } }, - EngineOperation::Export => match super::engine_export(app()).await { + EngineOperation::Export => match turbine.export().await { Ok(_) => 0, Err(err) => { eprintln!("{}", fmt_err(&*err)); @@ -71,6 +81,15 @@ where } }, EngineOperation::ExportErrorPage => { + // Assume the app has already been built and prepare the turbine + match turbine.populate_after_build().await { + Ok(_) => (), + Err(err) => { + eprintln!("{}", fmt_err(&err)); + return 1; + } + }; + // Get the HTTP status code to build from the arguments to this executable // We print errors directly here because we can, and because this behavior is // unique to the default engine @@ -98,7 +117,7 @@ where return 1; } }; - match super::engine_export_error_page(app(), code, output).await { + match turbine.export_error_page(code, output).await { Ok(_) => 0, Err(err) => { eprintln!("{}", fmt_err(&*err)); @@ -107,15 +126,27 @@ where } } EngineOperation::Serve => { - // To reduce friction for default servers and user-made servers, we - // automatically do the boilerplate that all servers would have to do - let props = match get_props(app()) { - Ok(props) => props, + // Assume the app has already been built and prepare the turbine + match turbine.populate_after_build().await { + Ok(_) => (), Err(err) => { - eprintln!("{}", fmt_err(&err)); + // Because so many people (including me) have made this mistake + eprintln!("{} (if you're running `perseus snoop serve`, make sure you've run `perseus snoop build` first!)", fmt_err(&err)); return 1; } }; + + // In production, automatically set the working directory + // to be the parent of the actual binary. This means that disabling + // debug assertions in development will lead to utterly incomprehensible + // errors! You have been warned! + if !cfg!(debug_assertions) { + 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(); + } + // This returns a `(String, u16)` of the host and port for maximum compatibility let addr = get_host_and_port(); // In production, give the user a heads up that something's actually happening @@ -126,10 +157,18 @@ where port = &addr.1 ); - serve_fn(props, addr).await; + // This actively and intentionally leaks the entire turbine to avoid the + // overhead of an `Arc`, since we're guaranteed to need an immutable + // reference to it for the server (we do this here so integration authors don't + // have to). Since this only runs once, there is no accumulation of + // unused memory, so this shouldn't be a problem. + let turbine_static = Box::leak(Box::new(turbine)); + + // We have access to default server options when `dflt-engine` is enabled + serve_fn(turbine_static, ServerOptions::default(), addr).await; 0 } - EngineOperation::Tinker => match super::engine_tinker(app()) { + EngineOperation::Tinker => match turbine.tinker() { Ok(_) => 0, Err(err) => { eprintln!("{}", fmt_err(&err)); diff --git a/packages/perseus/src/engine/export.rs b/packages/perseus/src/engine/export.rs deleted file mode 100644 index 34a29f3f5f..0000000000 --- a/packages/perseus/src/engine/export.rs +++ /dev/null @@ -1,244 +0,0 @@ -use crate::build::{build_app, BuildProps}; -use crate::export::{export_app, ExportProps}; -use crate::state::get_built_global_state; -use crate::{ - plugins::{PluginAction, Plugins}, - utils::get_path_prefix_server, - PerseusApp, SsrNode, -}; -use fs_extra::dir::{copy as copy_dir, CopyOptions}; -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; -use std::rc::Rc; - -use crate::errors::*; -use crate::{i18n::TranslationsManager, stores::MutableStore, PerseusAppBase}; - -/// Exports the app to static files, given a [`PerseusApp`]. This is -/// engine-agnostic, using the `exported` subfolder in the immutable store as a -/// destination directory. By default this will end up at `dist/exported/` -/// (customizable through [`PerseusApp`]). -/// -/// Note that this expects to be run in the root of the project. -pub async fn export<M: MutableStore, T: TranslationsManager>( - app: PerseusAppBase<SsrNode, M, T>, -) -> Result<(), Rc<Error>> { - let plugins = app.get_plugins(); - let static_aliases = app - .get_static_aliases() - .map_err(|err| Rc::new(err.into()))?; - // This won't have any trailing slashes (they're stripped by the immutable store - // initializer) - let dest = format!( - "{}/exported", - app.get_immutable_store() - .map_err(|err| Rc::new(err.into()))? - .get_path() - ); - let static_dir = app.get_static_dir(); - - build_and_export(app).await?; - // After that's done, we can do two copy operations in parallel at least - copy_static_aliases(&plugins, &static_aliases, &dest)?; - copy_static_dir(&plugins, &static_dir, &dest)?; - - plugins - .functional_actions - .export_actions - .after_successful_export - .run((), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - - Ok(()) -} - -/// Performs the building and exporting processes using the given app. This is -/// fully engine-agnostic, using only the data provided in the given -/// `PerseusApp`. -async fn build_and_export<M: MutableStore, T: TranslationsManager>( - app: PerseusAppBase<SsrNode, M, T>, -) -> Result<(), Rc<Error>> { - let plugins = app.get_plugins(); - - plugins - .functional_actions - .build_actions - .before_build - .run((), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - - let immutable_store = app - .get_immutable_store() - .map_err(|err| Rc::new(err.into()))?; - // We don't need this in exporting, but the build process does - let mutable_store = app.get_mutable_store(); - let gsc = app.get_global_state_creator(); - let locales = app.get_locales().map_err(|err| Rc::new(err.into()))?; - let templates_map = app - .get_atomic_templates_map() - .map_err(|err| Rc::new(err.into()))?; - let index_view_str = app.get_index_view_str(); - let root_id = app.get_root().map_err(|err| Rc::new(err.into()))?; - // This consumes `self`, so we get it finally - let translations_manager = app.get_translations_manager().await; - - // Build the site for all the common locales (done in parallel), denying any - // non-exportable features We need to build and generate those artifacts - // before we can proceed on to exporting - let build_res = build_app(BuildProps { - templates: &templates_map, - locales: &locales, - immutable_store: &immutable_store, - mutable_store: &mutable_store, - translations_manager: &translations_manager, - global_state_creator: &gsc, - exporting: true, - }) - .await; - if let Err(err) = build_res { - let err: Rc<Error> = Rc::new(err.into()); - plugins - .functional_actions - .export_actions - .after_failed_build - .run(err.clone(), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - return Err(err); - } - plugins - .functional_actions - .export_actions - .after_successful_build - .run((), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - // Get the global state that should've just been written - let global_state = get_built_global_state(&immutable_store) - .await - .map_err(|err| Rc::new(err.into()))?; - // The app has now been built, so we can safely instantiate the HTML shell - // (which needs access to the render config, generated in the above build step) - // It doesn't matter if the type parameters here are wrong, this function - // doesn't use them - let index_view = - PerseusApp::get_html_shell(index_view_str, &root_id, &immutable_store, &plugins) - .await - .map_err(|err| Rc::new(err.into()))?; - // Turn the build artifacts into self-contained static files - let export_res = export_app(ExportProps { - templates: &templates_map, - html_shell: index_view, - locales: &locales, - immutable_store: &immutable_store, - translations_manager: &translations_manager, - path_prefix: get_path_prefix_server(), - global_state: &global_state, - }) - .await; - if let Err(err) = export_res { - let err: Rc<Error> = Rc::new(err.into()); - plugins - .functional_actions - .export_actions - .after_failed_export - .run(err.clone(), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - return Err(err); - } - - Ok(()) -} - -/// Copies the static aliases into a distribution directory at `dest` (no -/// trailing `/`). This should be the root of the destination directory for the -/// exported files. Because this provides a customizable destination, it is -/// fully engine-agnostic. -/// -/// The error type here is a tuple of the location the asset was copied from, -/// the location it was copied to, and the error in that process (which could be -/// from `io` or `fs_extra`). -fn copy_static_aliases( - plugins: &Plugins<SsrNode>, - static_aliases: &HashMap<String, String>, - dest: &str, -) -> Result<(), Rc<Error>> { - // Loop through any static aliases and copy them in too - // Unlike with the server, these could override pages! - // We'll copy from the alias to the path (it could be a directory or a file) - // Remember: `alias` has a leading `/`! - for (alias, path) in static_aliases { - let from = PathBuf::from(path); - let to = format!("{}{}", dest, alias); - - if from.is_dir() { - if let Err(err) = copy_dir(&from, &to, &CopyOptions::new()) { - let err = EngineError::CopyStaticAliasDirErr { - source: err, - to, - from: path.to_string(), - }; - let err: Rc<Error> = Rc::new(err.into()); - plugins - .functional_actions - .export_actions - .after_failed_static_alias_dir_copy - .run(err.clone(), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - return Err(err); - } - } else if let Err(err) = fs::copy(&from, &to) { - let err = EngineError::CopyStaticAliasFileError { - source: err, - to, - from: path.to_string(), - }; - let err: Rc<Error> = Rc::new(err.into()); - plugins - .functional_actions - .export_actions - .after_failed_static_alias_file_copy - .run(err.clone(), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - return Err(err); - } - } - - Ok(()) -} - -/// Copies the directory containing static data to be put in `/.perseus/static/` -/// (URL). This takes in both the location of the static directory and the -/// destination directory for exported files. -fn copy_static_dir( - plugins: &Plugins<SsrNode>, - static_dir_raw: &str, - dest: &str, -) -> Result<(), Rc<Error>> { - // Copy the `static` directory into the export package if it exists - // If the user wants extra, they can use static aliases, plugins are unnecessary - // here - let static_dir = PathBuf::from(static_dir_raw); - if static_dir.exists() { - if let Err(err) = copy_dir( - &static_dir, - format!("{}/.perseus/", dest), - &CopyOptions::new(), - ) { - let err = EngineError::CopyStaticDirError { - source: err, - path: static_dir_raw.to_string(), - dest: dest.to_string(), - }; - let err: Rc<Error> = Rc::new(err.into()); - plugins - .functional_actions - .export_actions - .after_failed_static_copy - .run(err.clone(), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - return Err(err); - } - } - - Ok(()) -} diff --git a/packages/perseus/src/engine/export_error_page.rs b/packages/perseus/src/engine/export_error_page.rs deleted file mode 100644 index defe63fcd2..0000000000 --- a/packages/perseus/src/engine/export_error_page.rs +++ /dev/null @@ -1,78 +0,0 @@ -use crate::{ - errors::{EngineError, Error}, - i18n::TranslationsManager, - plugins::PluginAction, - server::build_error_page, - stores::MutableStore, - PerseusApp, PerseusAppBase, SsrNode, -}; -use std::{fs, rc::Rc}; - -/// Exports a single error page for the given HTTP status code to the given -/// output location. If the status code doesn't exist or isn't handled, then the -/// fallback page will be exported. -/// -/// This expects to run in the root of the project. -/// -/// This can only return IO errors from failures to write to the given output -/// location. (Wrapped in an `Rc` so they can be sent to plugins as well.) -pub async fn export_error_page( - app: PerseusAppBase<SsrNode, impl MutableStore, impl TranslationsManager>, - code: u16, - output: &str, -) -> Result<(), Rc<Error>> { - let plugins = app.get_plugins(); - - let error_pages = app.get_atomic_error_pages(); - // Prepare the HTML shell - let index_view_str = app.get_index_view_str(); - let root_id = app.get_root().map_err(|err| Rc::new(err.into()))?; - let immutable_store = app - .get_immutable_store() - .map_err(|err| Rc::new(err.into()))?; - // We assume the app has already been built before running this (so the render - // config must be available) It doesn't matter if the type parameters here - // are wrong, this function doesn't use them - let html_shell = - PerseusApp::get_html_shell(index_view_str, &root_id, &immutable_store, &plugins) - .await - .map_err(|err| Rc::new(err.into()))?; - - plugins - .functional_actions - .export_error_page_actions - .before_export_error_page - .run((code, output.to_string()), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - - // Build that error page as the server does - let err_page_str = build_error_page("", code, "", None, &error_pages, &html_shell); - - // Write that to the given output location - match fs::write(&output, err_page_str) { - Ok(_) => (), - Err(err) => { - let err = EngineError::WriteErrorPageError { - source: err, - dest: output.to_string(), - }; - let err: Rc<Error> = Rc::new(err.into()); - plugins - .functional_actions - .export_error_page_actions - .after_failed_write - .run(err.clone(), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - return Err(err); - } - }; - - plugins - .functional_actions - .export_error_page_actions - .after_successful_export_error_page - .run((), plugins.get_plugin_data()) - .map_err(|err| Rc::new(err.into()))?; - - Ok(()) -} diff --git a/packages/perseus/src/engine/get_op.rs b/packages/perseus/src/engine/get_op.rs index 1ddd52e8ee..7e376ae9e4 100644 --- a/packages/perseus/src/engine/get_op.rs +++ b/packages/perseus/src/engine/get_op.rs @@ -42,6 +42,7 @@ pub fn get_op() -> Option<EngineOperation> { } /// A representation of the server-side engine operations that can be performed. +#[derive(Debug, Clone, Copy)] pub enum EngineOperation { /// Run the server for the app. This assumes the app has already been built. Serve, diff --git a/packages/perseus/src/engine/mod.rs b/packages/perseus/src/engine/mod.rs index 6da6faf265..24e89d23f2 100644 --- a/packages/perseus/src/engine/mod.rs +++ b/packages/perseus/src/engine/mod.rs @@ -1,12 +1,3 @@ -mod build; -mod export; -mod export_error_page; -mod tinker; -pub use build::build as engine_build; -pub use export::export as engine_export; -pub use export_error_page::export_error_page as engine_export_error_page; -pub use tinker::tinker as engine_tinker; - #[cfg(feature = "dflt-engine")] mod dflt_engine; #[cfg(feature = "dflt-engine")] diff --git a/packages/perseus/src/engine/serve.rs b/packages/perseus/src/engine/serve.rs index 15c1e4ef70..b3fb9a938e 100644 --- a/packages/perseus/src/engine/serve.rs +++ b/packages/perseus/src/engine/serve.rs @@ -1,13 +1,4 @@ -use crate::errors::PluginError; -use crate::i18n::TranslationsManager; -use crate::plugins::PluginAction; -use crate::server::{ServerOptions, ServerProps}; -use crate::stores::MutableStore; -use crate::PerseusAppBase; -use futures::executor::block_on; use std::env; -use std::fs; -use sycamore::web::SsrNode; /// Gets the host and port to serve on based on environment variables, which are /// universally used for configuration regardless of engine. @@ -24,75 +15,3 @@ pub(crate) fn get_host_and_port() -> (String, u16) { (host, port) } - -/// Gets the properties to pass to the server, invoking plugin opportunities as -/// necessary. This is entirely engine-agnostic. -/// -/// WARNING: in production, this will automatically set the working directory -/// to be the parent of the actual binary! This means that disabling -/// debug assertions in development will lead to utterly incomprehensible -/// errors! You have been warned! -pub(crate) fn get_props<M: MutableStore, T: TranslationsManager>( - app: PerseusAppBase<SsrNode, M, T>, -) -> Result<ServerProps<M, T>, PluginError> { - if !cfg!(debug_assertions) { - 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(); - } - - let plugins = app.get_plugins(); - - plugins - .functional_actions - .server_actions - .before_serve - .run((), plugins.get_plugin_data())?; - - let static_dir_path = app.get_static_dir(); - - let app_root = app.get_root()?; - let immutable_store = app.get_immutable_store()?; - let index_view_str = app.get_index_view_str(); - // By the time this binary is being run, the app has already been built be the - // CLI (hopefully!), so we can depend on access to the render config - let index_view = block_on(PerseusAppBase::<SsrNode, M, T>::get_html_shell( - index_view_str, - &app_root, - &immutable_store, - &plugins, - ))?; - - let opts = ServerOptions { - // We don't support setting some attributes from `wasm-pack` through plugins/`PerseusApp` - // because that would require CLI changes as well (a job for an alternative engine) - html_shell: index_view, - 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(), - // This probably won't exist, but on the off chance that the user needs to support older - // browsers, we'll provide it anyway - wasm_js_bundle: "dist/pkg/perseus_engine_bg.wasm.js".to_string(), - templates_map: app.get_atomic_templates_map()?, - locales: app.get_locales()?, - root_id: app_root, - snippets: "dist/pkg/snippets".to_string(), - error_pages: app.get_atomic_error_pages(), - // This will be available directly at `/.perseus/static` - static_dir: if fs::metadata(&static_dir_path).is_ok() { - Some(static_dir_path) - } else { - None - }, - static_aliases: app.get_static_aliases()?, - }; - - Ok(ServerProps { - opts, - immutable_store, - mutable_store: app.get_mutable_store(), - global_state_creator: app.get_global_state_creator(), - translations_manager: block_on(app.get_translations_manager()), - }) -} diff --git a/packages/perseus/src/engine/tinker.rs b/packages/perseus/src/engine/tinker.rs deleted file mode 100644 index 6be54904d9..0000000000 --- a/packages/perseus/src/engine/tinker.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::errors::PluginError; -use crate::{i18n::TranslationsManager, stores::MutableStore}; -use crate::{plugins::PluginAction, PerseusAppBase, SsrNode}; - -/// Runs tinker plugin actions. -/// -/// Note that this expects to be run in the root of the project. -pub fn tinker( - app: PerseusAppBase<SsrNode, impl MutableStore, impl TranslationsManager>, -) -> Result<(), PluginError> { - let plugins = app.get_plugins(); - // Run all the tinker actions - // Note: this is deliberately synchronous, tinker actions that need a - // multithreaded async runtime should probably be making their own engines! - plugins - .functional_actions - .tinker - .run((), plugins.get_plugin_data())?; - - Ok(()) -} diff --git a/packages/perseus/src/error_pages.rs b/packages/perseus/src/error_pages.rs deleted file mode 100644 index 8a95c0f52f..0000000000 --- a/packages/perseus/src/error_pages.rs +++ /dev/null @@ -1,320 +0,0 @@ -use crate::translator::Translator; -#[cfg(target_arch = "wasm32")] -use crate::utils::replace_head; -use crate::Html; -use crate::SsrNode; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::rc::Rc; -use sycamore::prelude::Scope; -use sycamore::utils::hydrate::with_no_hydration_context; -use sycamore::view; -use sycamore::view::View; - -/// The function to a template the user must provide for error pages. This is -/// passed the status code, the error message, the URL of the 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<G> = - Box<dyn Fn(Scope, String, u16, String, Option<Rc<Translator>>) -> View<G> + Send + Sync>; -/// The function the user must provide to render the document `<head>` -/// associated with a certain error page. Note that this will only be rendered -/// on the server-side, and will be completely unreactive, being directly -/// interpolated into the document metadata on the client-side if the error page -/// is loaded. -pub type ErrorPageHeadTemplate = ErrorPageTemplate<SsrNode>; - -/// A representation of the views configured in an app for responding to errors. -/// -/// On the web, errors occur frequently beyond app logic, usually in -/// communication with servers, which will return [HTTP status codes](https://httpstatuses.com/) that indicate -/// a success or failure. If a non-success error code is received, then Perseus -/// will automatically render the appropriate error page, based on that status -/// code. If no page has been explicitly constructed for that status code, then -/// the fallback page will be used. -/// -/// Each error page is a closure returning a [`View`] that takes four -/// parameters: a reactive scope, the URL the user was on when the error -/// occurred (which they'll still be on, no route change occurs when rendering -/// an error page), the status code itself, a `String` of the actual error -/// message, and a [`Translator`] (which may not be available if the error -/// occurred before translations data could be fetched and processed, in which -/// case you should try to display language-agnostic information). -/// -/// The second closure to each error page is for the document `<head>` that will -/// be rendered in conjunction with that error page. Importantly, this is -/// completely unreactive, and is rendered to a string on the engine-side. -/// -/// In development, you can get away with not defining any error pages for your -/// app, as Perseus has a simple inbuilt default, though, when you try to go to -/// production (e.g. with `perseus deploy`), you'll receive an error message in -/// building. In other words, you must define your own error pages for release -/// mode. -pub struct ErrorPages<G: Html> { - status_pages: HashMap<u16, (ErrorPageTemplate<G>, ErrorPageHeadTemplate)>, - fallback: (ErrorPageTemplate<G>, ErrorPageHeadTemplate), -} -impl<G: Html> std::fmt::Debug for ErrorPages<G> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ErrorPages").finish() - } -} -impl<G: Html> ErrorPages<G> { - /// Creates a new definition of error pages with just a fallback page, which - /// will be used when an error occurs whose status code has not been - /// explicitly handled by some other error page. - pub fn new( - fallback_page: impl Fn(Scope, String, u16, String, Option<Rc<Translator>>) -> View<G> - + Send - + Sync - + 'static, - fallback_head: impl Fn(Scope, String, u16, String, Option<Rc<Translator>>) -> View<SsrNode> - + Send - + Sync - + 'static, - ) -> Self { - Self { - status_pages: HashMap::default(), - fallback: (Box::new(fallback_page), Box::new(fallback_head)), - } - } - /// Adds a new page for the given status code. If a page was already defined - /// for the given code, it will be updated by replacement, through the - /// mechanics of the internal `HashMap`. While there is no requirement - /// for this to be a valid HTTP status code, there would be no point in - /// defining a handler for a status code not on [this list](https://httpstatuses.com) - pub fn add_page( - &mut self, - status: u16, - page: impl Fn(Scope, String, u16, String, Option<Rc<Translator>>) -> View<G> - + Send - + Sync - + 'static, - head: impl Fn(Scope, String, u16, String, Option<Rc<Translator>>) -> View<SsrNode> - + Send - + Sync - + 'static, - ) { - self.status_pages - .insert(status, (Box::new(page), Box::new(head))); - } - /// 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 - /// a `Box`, which can be useful for plugins. - pub fn add_page_boxed( - &mut self, - status: u16, - page: ErrorPageTemplate<G>, - head: ErrorPageHeadTemplate, - ) { - self.status_pages.insert(status, (page, head)); - } - /// Gets the internal template function to render. - fn get_template_fn(&self, status: u16) -> &ErrorPageTemplate<G> { - // Check if we have an explicitly defined page for this status code - // If not, we'll render the fallback page - match self.status_pages.contains_key(&status) { - true => &self.status_pages.get(&status).unwrap().0, - false => &self.fallback.0, - } - } - /// Gets the `View<G>` to render the content. - pub fn get_view( - &self, - cx: Scope, - url: &str, - status: u16, - err: &str, - translator: Option<Rc<Translator>>, - ) -> View<G> { - let template_fn = self.get_template_fn(status); - template_fn(cx, url.to_string(), status, err.to_string(), translator) - } - /// Gets the `View<G>` to render the content and automatically renders and - /// replaces the document `<head>` appropriately. - #[cfg(target_arch = "wasm32")] - pub fn get_view_and_render_head( - &self, - cx: Scope, - url: &str, - status: u16, - err: &str, - translator: Option<Rc<Translator>>, - ) -> View<G> { - let head = self.render_head(url, status, err, translator.clone()); - replace_head(&head); - self.get_view(cx, url, status, err, translator) - } - /// Renders the head of an error page to a `String`. - /// - /// This is needed on the browser-side to render error pages that occur - /// abruptly. - pub fn render_head( - &self, - url: &str, - status: u16, - err: &str, - translator: Option<Rc<Translator>>, - ) -> String { - let head_fn = match self.status_pages.contains_key(&status) { - true => &self.status_pages.get(&status).unwrap().1, - false => &self.fallback.1, - }; - sycamore::render_to_string(|cx| { - with_no_hydration_context(|| { - head_fn(cx, url.to_string(), status, err.to_string(), translator) - }) - }) - } -} -// #[cfg(target_arch = "wasm32")] -// impl ErrorPages<DomNode> { -// /// Renders the appropriate error page to the given DOM container. -// pub fn render_page( -// &self, -// cx: Scope, -// url: &str, -// status: u16, -// err: &str, -// translator: Option<Rc<Translator>>, -// container: &Element, -// ) { -// let template_fn = self.get_template_fn(status); -// // Render that to the given container -// sycamore::render_to( -// |_| template_fn(cx, url.to_string(), status, err.to_string(), -// translator), container, -// ); -// } -// } -// #[cfg(target_arch = "wasm32")] -// impl ErrorPages<HydrateNode> { -// /// Hydrates the appropriate error page to the given DOM container. This -// is /// used for when an error page is rendered by the server and then -// needs /// interactivity. -// pub fn hydrate_page( -// &self, -// cx: Scope, -// url: &str, -// status: u16, -// err: &str, -// translator: Option<Rc<Translator>>, -// container: &Element, -// ) { -// let template_fn = self.get_template_fn(status); -// let hydrate_view = template_fn(cx, url.to_string(), status, -// err.to_string(), translator); // TODO Now convert that `HydrateNode` -// to a `DomNode` let dom_view = hydrate_view; -// // Render that to the given container -// sycamore::hydrate_to(|_| dom_view, container); -// } -// /// Renders the appropriate error page to the given DOM container. This -// is /// implemented on `HydrateNode` to avoid having to have two `Html` -// type /// parameters everywhere (one for templates and one for error -// pages). // TODO Convert from a `HydrateNode` to a `DomNode` -// pub fn render_page( -// &self, -// cx: Scope, -// url: &str, -// status: u16, -// err: &str, -// translator: Option<Rc<Translator>>, -// container: &Element, -// ) { -// let template_fn = self.get_template_fn(status); -// // Render that to the given container -// sycamore::hydrate_to( -// |_| template_fn(cx, url.to_string(), status, err.to_string(), -// translator), container, -// ); -// } -// } -#[cfg(not(target_arch = "wasm32"))] -impl ErrorPages<SsrNode> { - /// Renders the error page to a string. This should then be hydrated on the - /// client-side. No reactive scope is provided to this function, it uses an - /// internal one. - pub fn render_to_string( - &self, - url: &str, - status: u16, - err: &str, - translator: Option<Rc<Translator>>, - ) -> String { - let template_fn = self.get_template_fn(status); - // Render that to the given container - sycamore::render_to_string(|cx| { - template_fn(cx, url.to_string(), status, err.to_string(), translator) - }) - } - /// Renders the error page to a string, using the given reactive scope. Note - /// that this function is not used internally, and `.render_to_string()` - /// should cover all uses. This is included for completeness. - pub fn render_to_string_scoped( - &self, - cx: Scope, - url: &str, - status: u16, - err: &str, - translator: Option<Rc<Translator>>, - ) -> String { - let template_fn = self.get_template_fn(status); - // Render that to the given container - sycamore::render_to_string(|_| { - template_fn(cx, url.to_string(), status, err.to_string(), translator) - }) - } -} -// We provide default error pages to speed up development, but they have to be -// added before moving to production (or we'll `panic!`) -impl<G: Html> Default for ErrorPages<G> { - #[cfg(debug_assertions)] - fn default() -> Self { - let mut error_pages = Self::new( - |cx, url, status, err, _| { - view! { cx, - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Error" } - } - }, - ); - // 404 is the most common by far, so we add a little page for that too - error_pages.add_page( - 404, - |cx, _, _, _, _| { - view! { cx, - p { "Page not found." } - } - }, - |cx, _, _, _, _| { - view! { cx, - title { "Not Found" } - } - }, - ); - - error_pages - } - #[cfg(not(debug_assertions))] - fn default() -> Self { - panic!("you must provide your own error pages in production") - } -} - -/// A representation of an error page, particularly for storage in transit so -/// that server-side rendered error pages can be hydrated on the client-side. -#[derive(Serialize, Deserialize, Debug)] -pub struct ErrorPageData { - /// The URL for the error. - pub url: String, - /// THe HTTP status code that corresponds with the error. - pub status: u16, - /// The actual error message as a string. - pub err: String, -} diff --git a/packages/perseus/src/error_views.rs b/packages/perseus/src/error_views.rs new file mode 100644 index 0000000000..de52cd8b04 --- /dev/null +++ b/packages/perseus/src/error_views.rs @@ -0,0 +1,407 @@ +use crate::{errors::*, reactor::Reactor}; +#[cfg(not(target_arch = "wasm32"))] +use crate::{i18n::Translator, reactor::RenderMode, state::TemplateState}; +use fmterr::fmt_err; +use serde::{Deserialize, Serialize}; +#[cfg(target_arch = "wasm32")] +use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use sycamore::prelude::create_scope_immediate; +#[cfg(target_arch = "wasm32")] +use sycamore::prelude::{create_child_scope, try_use_context, ScopeDisposer}; +use sycamore::{ + prelude::{view, Scope}, + utils::hydrate::with_no_hydration_context, + view::View, + web::{Html, SsrNode}, +}; + +/// The error handling system of an app. In Perseus, errors come in several +/// forms, all of which must be handled. This system provides a way to do this +/// automatically, maximizing your app's error tolerance, including against +/// panics. +pub struct ErrorViews<G: Html> { + /// The central function that parses the error provided and returns a tuple + /// of views to deal with it: the first view is the document metadata, + /// and the second the body of the error. + #[allow(clippy::type_complexity)] + handler: Box< + dyn Fn(Scope, ClientError, ErrorContext, ErrorPosition) -> (View<SsrNode>, View<G>) + + Send + + Sync, + >, + /// A function for determining if a subsequent load error should occupy the + /// entire page or not. If this returns `true`, the whole page will be + /// taken over (e.g. for a 404), but, if it returns `false`, a small + /// popup will be created on the current page (e.g. for an internal + /// error unrelated to the page itself). + /// + /// This is left to user discretion in the case of subsequent loads. For + /// initial loads, we will render a page-wide error only if it came from + /// the engine, otherwise just a popup over the prerendered content so + /// the user can proceed with visibility, but not interactivity. + subsequent_load_determinant: Box<dyn Fn(&ClientError) -> bool + Send + Sync>, + /// A verbatim copy of the user's handler, intended for panics. This is + /// needed because we have to extract it completely and give it to the + /// standard library in a thread-safe manner (even though Wasm is + /// single-threaded). + /// + /// This will be extracted by the `PerseusApp` creation process and put in a + /// place where it can be safely extracted. The replacement function + /// will panic if called, so this should **never** be manually executed. + #[cfg(target_arch = "wasm32")] + panic_handler: Arc< + dyn Fn(Scope, ClientError, ErrorContext, ErrorPosition) -> (View<SsrNode>, View<G>) + + Send + + Sync, + >, +} +impl<G: Html> std::fmt::Debug for ErrorViews<G> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ErrorViews").finish_non_exhaustive() + } +} +impl<G: Html> ErrorViews<G> { + /// Creates an error handling system for your app with the given handler + /// function. This will be provided a [`ClientError`] to match against, + /// along with an [`ErrorContext`], which tells you what you have available + /// to you (since, in some critical errors, you might not even have a + /// translator). + /// + /// The function given to this should return a tuple of two `View`s: the + /// first to be placed in document `<head>`, and the second + /// for the body. For views with `ErrorPosition::Popup` or + /// `ErrorPosition::Widget`, the head view will be ignored, + /// and would usually be returned as `View::empty()`. + pub fn new( + handler: impl Fn(Scope, ClientError, ErrorContext, ErrorPosition) -> (View<SsrNode>, View<G>) + + Send + + Sync + + Clone + + 'static, + ) -> Self { + #[allow(clippy::redundant_clone)] + Self { + handler: Box::new(handler.clone()), + // Sensible defaults are fine here + subsequent_load_determinant: Box::new(|err| { + match err { + // Any errors from the server should take up the whole page + ClientError::ServerError { .. } => true, + // Anything else is internal-ish (e.g. a fetch failure would be a network + // failure, so we keep the user where they are) + _ => false, + } + }), + #[cfg(target_arch = "wasm32")] + panic_handler: Arc::new(handler), + } + } + /// Sets the function that determines if an error on a *subsequent load* + /// should be presented to the user as taking up the whole page, or just + /// being in a little popup. Usually, you can leave this as the default, + /// which will display any internal errors as popups, and any errors from + /// the server (e.g. a 404 not found) as full pages. + /// + /// You could use this to create extremely unorthodox patterns like + /// rendering a popup on the current page if the user clicks a link that + /// goes to a 404, if you really wanted. + /// + /// For widgets, returning `true` from the function you provide to this will + /// take up the whole widget, as opposed to the whole page. + /// + /// *Note: if you want all your errors to take up the whole page no matter + /// what (not recommended, see the book for why!), you should leave this + /// function as the default and simply style `#__perseus_error_popup` to + /// take up the whole page.* + pub fn subsequent_load_determinant_fn( + &mut self, + val: impl Fn(&ClientError) -> bool + Send + Sync + 'static, + ) -> &mut Self { + self.subsequent_load_determinant = Box::new(val); + self + } + + /// Returns `true` if the given error, which must have occurred during a + /// subsequent load, should be displayed as a popup, as opposed to + /// occupying the entire page/widget. + #[cfg(target_arch = "wasm32")] + pub(crate) fn subsequent_err_should_be_popup(&self, err: &ClientError) -> bool { + !(self.subsequent_load_determinant)(err) + } + + /// Force-sets the unlocalized defaults. If you really want to use the + /// default error pages in production, this will allow you to (where + /// they would normally fail if you simply specified nothing). + /// + /// **Warning:** these defaults are completely unlocalized, unstyled, and + /// intended for development! You will be able to use these by not + /// specifying any `.error_views()` on your `PerseusApp` in development, + /// and you should only use this function if you're doing production + /// testing of Perseus, and you don't particularly want to write + /// your own error pages. + /// + /// Note that this is used throughout the Perseus examples for brevity. + pub fn unlocalized_development_default() -> Self { + // Because this is an unlocalized, extremely simple default, we don't care about + // capabilities or positioning + Self::new(|cx, err, _, _| { + match err { + // Special case for 404 due to its frequency + ClientError::ServerError { status, .. } if status == 404 => ( + view! { cx, + title { "Page not found" } + }, + view! { cx, + p { "Page not found." } + }, + ), + err => { + let err_msg = fmt_err(&err); + ( + view! { cx, + title { "Error" } + }, + view! { cx, + (format!("An error occurred: {}", err_msg)) + }, + ) + } + } + }) + } +} +#[cfg(target_arch = "wasm32")] +impl<G: Html> ErrorViews<G> { + /// Invokes the user's handling function, producing head/body views for the + /// given error. From the given scope, this will determine the + /// conditions under which the error can be rendered. + pub(crate) fn handle<'a>( + &self, + cx: Scope<'a>, + err: ClientError, + pos: ErrorPosition, + ) -> (String, View<G>, ScopeDisposer<'a>) { + let reactor = try_use_context::<Reactor<G>>(cx); + // From the given scope, we can perfectly determine the capabilities this error + // view will have + let info = match reactor { + Some(reactor) => match reactor.try_get_translator() { + Some(_) => ErrorContext::Full, + None => ErrorContext::WithReactor, + }, + None => ErrorContext::Static, + }; + + let mut body_view = View::empty(); + let mut head_str = String::new(); + let disposer = create_child_scope(cx, |child_cx| { + let (head_view, body_view_local) = (self.handler)(child_cx, err, info, pos); + body_view = body_view_local; + // Stringify the head view with no hydration markers + head_str = sycamore::render_to_string(|_| with_no_hydration_context(|| head_view)); + }); + + (head_str, body_view, disposer) + } + /// Extracts the panic handler from within the error views. This should + /// generally only be called by `PerseusApp`'s error views instantiation + /// system. + pub(crate) fn take_panic_handler( + &mut self, + ) -> Arc< + dyn Fn(Scope, ClientError, ErrorContext, ErrorPosition) -> (View<SsrNode>, View<G>) + + Send + + Sync, + > { + std::mem::replace( + &mut self.panic_handler, + Arc::new(|_, _, _, _| unreachable!()), + ) + } +} +#[cfg(not(target_arch = "wasm32"))] +impl ErrorViews<SsrNode> { + /// Renders an error view on the engine-side. This takes an optional + /// translator. This will return a tuple of `String`ified views for the + /// head and body. For widget errors, the former should be discarded. + /// + /// Since the only kind of error that can be sent from the server to the + /// client falls under a `ClientError::ServerError`, which always takes + /// up the whole page, and since we presumably don't have any actual + /// content to render, this will, expectedly, take up the whole page. + /// + /// This cannot be used for widgets (use `.handle_widget()` instead). + /// + /// # Hydration + /// + /// At present, due to the difficulties of controlling hydration contexts + /// in a fine-grained manner, Perseus does not hydrate error views + /// whatsoever. This is compounded by the problem of exported error + /// views, which do not have access to locales, whereas their + /// browser-side-rendered counterparts do. To avoid hydration mismatches + /// and unnecessary development panics, hydration is therefore disabled + /// for error views. + pub(crate) fn render_to_string( + &self, + err: ServerErrorData, + translator: Option<&Translator>, + ) -> (String, String) { + // We need to create an engine-side reactor + let reactor = + Reactor::<SsrNode>::engine(TemplateState::empty(), RenderMode::Error, translator); + let mut body_str = String::new(); + let mut head_str = String::new(); + create_scope_immediate(|cx| { + reactor.add_self_to_cx(cx); + // Depending on whether or not we had a translator, we can figure out the + // capabilities + let err_cx = match translator { + // On the engine-side, we don't get global state (see docs for + // `ErrorContext::FullNoGlobal`) + Some(_) => ErrorContext::FullNoGlobal, + None => ErrorContext::WithReactor, + }; + // NOTE: No hydration context + let (head_view, body_view) = (self.handler)( + cx, + ClientError::ServerError { + status: err.status, + message: err.msg, + }, + err_cx, + ErrorPosition::Page, + ); + + head_str = sycamore::render_to_string(|_| with_no_hydration_context(|| head_view)); + body_str = sycamore::render_to_string(|_| body_view); + }); + + (head_str, body_str) + } +} +impl<G: Html> ErrorViews<G> { + /// Renders an error view for the given widget, using the given scope. This + /// will *not* create a new child scope, it will simply use the one it is + /// given. + /// + /// Since this only handles widgets, it will automatically discard the head. + /// + /// This assumes the reactor has already been fully set up with a translator + /// on the given context, and hence this will always use + /// `ErrorContext::Full` (since widgets shoudl not be rendered if a + /// translator cannot be found, and certainly not if a reactor could not + /// be instantiated). + pub(crate) fn handle_widget(&self, err: ClientError, cx: Scope) -> View<G> { + let (_head, body) = (self.handler)(cx, err, ErrorContext::Full, ErrorPosition::Page); + body + } +} + +/// The context of an error, which determines what is available to your views. +/// This *must* be checked before using things like translators, which may not +/// be available, depending on the information in here. +#[derive(Debug, Clone, Copy)] +pub enum ErrorContext { + /// Perseus has suffered an unrecoverable error in initialization, and + /// routing/interactivity is impossible. Your error view will be + /// rendered to the page, and then Perseus will terminate completely. + /// This means any buttons, handlers, etc. *will not run*! + /// + /// If you're having trouble with this, imagine printing out your error + /// view. That's the amount of functionality you get (except that the + /// browser will automatically take over any links). If you want + /// interactivity, you *could* use `dangerously_set_inner_html` to create + /// some JS handlers, for instance for offering the user a button to + /// reload the page. + Static, + /// Perseus suffered an error before it was able to create a translator. + /// Your error view will be rendered inside a proper router, and you'll + /// have a [`Reactor`] available in context, but using the `t!` or + /// `link!` macros will lead to a panic. If you present links to other pages + /// in the app, the user will be able to press them, and these will try + /// to set up a translator, but this may fail. + /// + /// If your app doesn't use internationalization, Perseus does still have a + /// dummy translator internally, so this doesn't completely evaporate, + /// but you can ignore it. + /// + /// *Note: currently, if the user goes to, say + /// `/en-US/this-page-does-not-exist`, even though the page is clearly + /// localized, Perseus will not provide a translator. This will be rectified + /// in a future version. If the user attempted to switch locales, and + /// there was an error fetching translations for the new one, the old + /// translator will be provided here.* + WithReactor, + /// Perseus was able to successfully instantiate a reactor and translator, + /// but this error view is being rendered on the engine-side, and there is + /// no global state available. + /// + /// Although global state could theoretically be provided to error pages + /// *sometimes*, the complexity and cloning involved make this extremely + /// nuanced (e.g. exported error pages can't access localized global + /// state because they don't know their locale, global state might be + /// only partially built at the time of the error, etc.). In + /// general, error views rendered on the engine-side will have this (though + /// not always). + FullNoGlobal, + /// Perseus was able to successfully instantiate everything, including a + /// translator, but then it encountered an error. You have access to all + /// the usual things you would have in a page here. + /// + /// Note that this would also be given to you on the engine-side when you + /// have a translator available, but when you're still rendering to an + /// [`SsrNode`]. + Full, +} + +/// Where an error is being rendered. Most of the time, you'll use this for +/// determining how you want to style an error view. For instance, you probably +/// don't want giant text saying "Page not found!" if the error is actually +/// going to be rendered inside a tiny little widget. +/// +/// Note that you should also always check if you have a `Popup`-style error, in +/// which case there will be no router available, so any links will be handled +/// by the browser's default behavior. +#[derive(Clone, Copy, Debug)] +pub enum ErrorPosition { + /// The error will take up the whole page. + Page, + /// The error will be confined to the widget that caused it. + Widget, + /// The error is being rendered in a little popup, and no router is + /// available. + /// + /// This is usually reserved for internal errors, where something has gone + /// severely wrong. + Popup, +} + +/// The information to render an error on the server-side, which is usually +/// associated with an explicit HTTP status code. +/// +/// Note that these will never be generated at build-time, any problems there +/// will simply cause an error. However, errors in the build process during +/// incremental generation *will* return one of these. +/// +/// This `struct` is embedded in the HTML provided to the client, allowing it to +/// be extracted and rendered. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ServerErrorData { + /// The HTTP status code of the error (since these errors are always + /// transmitted from server to client). + pub(crate) status: u16, + /// The actual error message. In error pages that are exported, this will be + /// simply the `reason-phrase` for the referenced status code, + /// containing no more information, since it isn't available at + /// export-time, of course. + pub(crate) msg: String, +} + +// --- Default error views (development only) --- +#[cfg(debug_assertions)] // This will fail production compilation neatly +impl<G: Html> Default for ErrorViews<G> { + fn default() -> Self { + Self::unlocalized_development_default() + } +} diff --git a/packages/perseus/src/errors.rs b/packages/perseus/src/errors.rs index 98a854d454..50a5e38e2d 100644 --- a/packages/perseus/src/errors.rs +++ b/packages/perseus/src/errors.rs @@ -25,7 +25,7 @@ pub enum Error { pub struct PluginError { pub name: String, #[source] - pub source: Box<dyn std::error::Error>, + pub source: Box<dyn std::error::Error + Send + Sync>, } /// Errors that can occur in the server-side engine system (responsible for @@ -66,45 +66,190 @@ pub enum EngineError { } /// Errors that can occur in the browser. +/// +/// **Important:** any changes to this `enum` constitute a breaking change, +/// since users match this in their error pages. Changes in underlying +/// `enum`s are not considered breaking (e.g. introducing a new invariant +/// error). +/// +/// **Warning:** in all these cases, except `ClientError::ServerError`, the user +/// can already see the prerendered version of the page, it just isn't +/// interactive. Only in that case will your error page occupy the entire +/// screen, otherwise it will be placed into a `div` with the class +/// `__perseus-error`, a deliberate choice to reinforce the best practice of +/// giving the user as much as possible (it might not be interactive, but they +/// can still use a rudimentary version). See the book for further details. +/// +/// # Panic handling +/// In a rather unorthodox manner, Perseus will do its level best to get an +/// error message to the user of your app, no matter what happens. For this +/// reason, this `enum` includes a `Panic` variant that will be provided when a +/// panic has been intercepted. In this case, your error view will be rendered +/// with no reactor, translations, or anything else available to it. What you do +/// at this time is extremely important, since any panics in the code that +/// handles that variant **cannot be caught**, leaving the user with no error +/// message and an app that has completely frozen. +/// +/// The `Panic` variant on this type only provides a formatted panic message, +/// and nothing else from [`std::panic::PanicInfo`], due to lifetime +/// constraints. Since the message formatting is done by the standard library, +/// which automatically takes account of the `payload` and `message`, the only +/// other properties are `location` and `can_unwind`: the latter should be +/// handled by Perseus if it ever is, and the former shoudl not be exposed to +/// end users. Currently, there is no way to get the underlying `PanicInfo` +/// through Perseus' error handling system (although a plugin could do it +/// by overriding the panic handler, but this is usually a bad idea). #[derive(Error, Debug)] pub enum ClientError { - #[error("locale '{locale}' is not supported")] - LocaleNotSupported { locale: String }, - /// This converts from a `JsValue` or the like. - #[error("the following error occurred while interfacing with JavaScript: {0}")] - Js(String), + #[error("{0}")] // All formatted for us by `std` + Panic(String), + #[error(transparent)] + PluginError(#[from] PluginError), + #[error(transparent)] + InvariantError(#[from] ClientInvariantError), + #[error(transparent)] + ThawError(#[from] ClientThawError), + // Not like the `ServerError` in this file! + #[error("an error with HTTP status code '{status}' was returned by the server: '{message}'")] + ServerError { + status: u16, + // This has to have been serialized unfortunately + message: String, + }, #[error(transparent)] FetchError(#[from] FetchError), - #[error("invalid frozen state provided")] - ThawFailed { + #[error(transparent)] + PlatformError(#[from] ClientPlatformError), + #[error(transparent)] + PreloadError(#[from] ClientPreloadError), /* #[error(transparent)] + * FetchError(#[from] FetchError), + * , + * // If the user is using the template macros, this should never be emitted because we can + * // ensure that the generated state is valid + * #[error("tried to deserialize invalid state + * (it was not malformed, but the state was not + * of + * the declared type)")] StateInvalid { + * #[source] + * source: serde_json::Error, + * }, + * #[error("server informed us that a valid + * locale was invald (this almost certainly + * requires + * a hard reload)")] ValidLocaleNotProvided { + * locale: String }, + */ +} + +/// Errors that can occur in the browser from certain invariants not being +/// upheld. These should be extremely rare, but, since we don't control what +/// HTML the browser gets, we avoid panicking in these cases. +/// +/// Note that some of these invariants may be broken by an app's own code, such +/// as invalid global state downcasting. +#[derive(Debug, Error)] +pub enum ClientInvariantError { + #[error("the render configuration was not found, or was malformed")] + RenderCfg, + #[error("the global state was not found, or was malformed (even apps not using global state should have an empty one injected)")] + GlobalState, + // This won't be triggered for HSR + #[error("attempted to register state on a page/capsule that had been previously declared as having no state")] + IllegalStateRegistration, + #[error( + "attempted to downcast reactive global state to the incorrect type (this is an error)" + )] + GlobalStateDowncast, + // This is technically a typing error, but we do the typing internally, so this should be + // impossible + #[error("invalid page/widget state found")] + InvalidState { #[source] source: serde_json::Error, }, - // If the user is using the template macros, this should never be emitted because we can - // ensure that the generated state is valid - #[error("tried to deserialize invalid state (it was not malformed, but the state was not of the declared type)")] - StateInvalid { + // Invariant because the user would have had to call something like `.template_with_state()` + // for this to happen + #[error("no state was found for a page/widget that expected state (you might have forgotten to write a state generation function, like `get_build_state`)")] + NoState, + #[error("the initial state was not found, or was malformed")] + InitialState, + #[error("the initial state denoted an error, but this was malformed")] + InitialStateError { #[source] source: serde_json::Error, }, - #[error("server informed us that a valid locale was invald (this almost certainly requires a hard reload)")] + #[error( + "the locale '{locale}', which is supported by this app, was not returned by the server" + )] ValidLocaleNotProvided { locale: String }, - #[error("the given path for preloading leads to a locale detection page; you probably wanted to wrap the path in `link!(...)`")] - PreloadLocaleDetection, - #[error("the given path for preloading was not found")] - PreloadNotFound, + // This is just for initial loads (`__PERSEUS_TRANSLATIONS` window variable) + #[error("the translations were not found, or were malformed (even apps not using i18n have a declaration of their lack of translations)")] + Translations, + #[error("we found the current page to be a 404, but the engine disagrees")] + RouterMismatch, + #[error("the widget states were not found, or were malformed (even pages not using widgets still have a declaration of these)")] + WidgetStates, + #[error("a widget was registered in the state store with only a head (but widgets do not have heads), implying a corruption")] + InvalidWidgetPssEntry, + #[error("the widget with path '{path}' was not found, indicating you are rendering an invalid widget on the browser-side only (you should refactor to always render the widget, but only have it do anything on the browser-side; that way, it can be verified on the engine-side, leading to errors at build-time rather than execution-time)")] + BadWidgetRouteMatch { path: String }, +} + +/// Errors that can occur as a result of user-instructed preloads. Note that +/// this will not cover network-related errors, which are considered fetch +/// errors (since they are likely not the fault of your code, whereas a +/// `ClientPreloadError` probably is). +#[derive(Debug, Error)] +pub enum ClientPreloadError { + #[error("preloading '{path}' leads to a locale detection page, which implies a malformed url")] + PreloadLocaleDetection { path: String }, + #[error("'{path}' was not found for preload")] + PreloadNotFound { path: String }, +} + +/// Errors that can occur in the browser while interfacing with browser +/// functionality. These should never really occur unless you're operating in an +/// extremely alien environment (which probably wouldn't support Wasm, but +/// we try to allow maximal error page control). +#[derive(Debug, Error)] +pub enum ClientPlatformError { + #[error("failed to get current url for initial load determination")] + InitialPath, +} + +/// Errors that can occur in the browser as a result of attempting to thaw +/// provided state. +#[derive(Debug, Error)] +pub enum ClientThawError { + #[error("invalid frozen page/widget state")] + InvalidFrozenState { + #[source] + source: serde_json::Error, + }, + #[error("invalid frozen global state")] + InvalidFrozenGlobalState { + #[source] + source: serde_json::Error, + }, + #[error("this app uses global state, but the provided frozen state declared itself to have no global state")] + NoFrozenGlobalState, + #[error("invalid frozen app provided (this is likely a corruption)")] + InvalidFrozenApp { + #[source] + source: serde_json::Error, + }, } /// Errors that can occur in the build process or while the server is running. #[cfg(not(target_arch = "wasm32"))] #[derive(Error, Debug)] pub enum ServerError { - #[error("render function '{fn_name}' in template '{template_name}' failed (cause: {cause:?})")] + #[error("render function '{fn_name}' in template '{template_name}' failed (cause: {blame:?})")] RenderFnFailed { // This is something like `build_state` fn_name: String, template_name: String, - cause: ErrorCause, + blame: ErrorBlame, // This will be triggered by the user's custom render functions, which should be able to // have any error type #[source] @@ -135,8 +280,21 @@ pub enum ServerError { #[source] source: serde_json::Error, }, + + // `PathWithoutLocale` + #[error("attempting to resolve dependency '{widget}' in locale '{locale}' produced a locale redirection verdict (this shouldn't be possible)")] + ResolveDepLocaleRedirection { widget: String, locale: String }, + #[error("attempting to resolve dependency '{widget}' in locale '{locale}' produced a not found verdict (did you mistype the widget path?)")] + ResolveDepNotFound { widget: String, locale: String }, + + #[error("template '{template_name}' cannot be built at build-time due to one or more of its dependencies having state that may change later; to allow this template to be built later, add `.allow_rescheduling()` to your template definition")] + TemplateCannotBeRescheduled { template_name: String }, + // This is a serious error in programming + #[error("a dependency tree was not resolved, but a function expecting it to have been was called (this is a server-side error)")] + DepTreeNotResolved, #[error("the template name did not prefix the path (this request was severely malformed)")] TemplateNameNotInPath, + #[error(transparent)] StoreError(#[from] StoreError), #[error(transparent)] @@ -147,6 +305,11 @@ pub enum ServerError { ExportError(#[from] ExportError), #[error(transparent)] ServeError(#[from] ServeError), + #[error(transparent)] + PluginError(#[from] PluginError), + // This can occur in state acquisition failures during prerendering + #[error(transparent)] + ClientError(#[from] ClientError), } /// Converts a server error into an HTTP status code. #[cfg(not(target_arch = "wasm32"))] @@ -154,9 +317,9 @@ pub fn err_to_status_code(err: &ServerError) -> u16 { match err { ServerError::ServeError(ServeError::PageNotFound { .. }) => 404, // Ambiguous (user-generated error), we'll rely on the given cause - ServerError::RenderFnFailed { cause, .. } => match cause { - ErrorCause::Client(code) => code.unwrap_or(400), - ErrorCause::Server(code) => code.unwrap_or(500), + ServerError::RenderFnFailed { blame, .. } => match blame { + ErrorBlame::Client(code) => code.unwrap_or(400), + ErrorBlame::Server(code) => code.unwrap_or(500), }, // Any other errors go to a 500, they'll be misconfigurations or internal server errors _ => 500, @@ -187,24 +350,51 @@ pub enum StoreError { /// Errors that can occur while fetching a resource from the server. #[derive(Error, Debug)] pub enum FetchError { - #[error("asset fetched from '{url}' wasn't a string")] - NotString { url: String }, - #[error("asset fetched from '{url}' returned status code '{status}' (expected 200)")] + #[error("asset of type '{ty}' fetched from '{url}' wasn't a string")] + NotString { url: String, ty: AssetType }, + #[error( + "asset of type '{ty}' fetched from '{url}' returned status code '{status}' (expected 200)" + )] NotOk { url: String, status: u16, // The underlying body of the HTTP error response err: String, + ty: AssetType, }, - #[error("asset fetched from '{url}' couldn't be serialized")] + #[error("asset of type '{ty}' fetched from '{url}' couldn't be serialized")] SerFailed { url: String, #[source] source: Box<dyn std::error::Error + Send + Sync>, + ty: AssetType, }, // This is not used by the `fetch` function, but it is used by the preloading system - #[error("asset not found")] - NotFound { url: String }, + #[error("preload asset fetched from '{url}' was not found")] + PreloadNotFound { url: String, ty: AssetType }, + /// This converts from a `JsValue` or the like. + #[error("the following error occurred while interfacing with JavaScript: {0}")] + Js(String), +} + +/// The type of an asset fetched from the server. This allows distinguishing +/// between errors in fetching, say, pages, vs. translations, which you may wish +/// to handle differently. +#[derive(Debug, Clone, Copy)] +pub enum AssetType { + /// A page in the app. + Page, + /// A widget in the app. + Widget, + /// Translations for a locale. + Translations, + /// A page/widget the user asked to have preloaded. + Preload, +} +impl std::fmt::Display for AssetType { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{:?}", self) + } } /// Errors that can occur while building an app. @@ -228,7 +418,7 @@ pub enum BuildError { InvalidDatetimeIntervalIndicator { indicator: String }, #[error("asset 'render_cfg.json' invalid or corrupted (try cleaning all assets)")] RenderCfgInvalid { - #[from] + #[source] source: serde_json::Error, }, } @@ -243,19 +433,24 @@ pub enum ExportError { TemplateNotFound { template_name: String }, #[error("your app can't be exported because its global state depends on strategies that can't be run at build time (only build state can be used in exportable apps)")] GlobalStateNotExportable, + #[error("template '{template_name} can't be exported because one or more of its widget dependencies use state generation strategies that can't be run at build-time")] + DependenciesNotExportable { template_name: String }, + // This is used in error page exports + #[error("invalid status code provided for error page export (please provide a valid http status code)")] + InvalidStatusCode, } /// Errors that can occur while serving an app. These are integration-agnostic. #[derive(Error, Debug)] pub enum ServeError { - #[error("page at '{path}' not found")] + #[error("page/widget at '{path}' not found")] PageNotFound { path: String }, #[error("both build and request states were defined for a template when only one or fewer were expected (should it be able to amalgamate states?)")] BothStatesDefined, #[cfg(not(target_arch = "wasm32"))] #[error("couldn't parse revalidation datetime (try cleaning all assets)")] BadRevalidate { - #[from] + #[source] source: chrono::ParseError, }, } @@ -263,109 +458,58 @@ pub enum ServeError { /// Defines who caused an ambiguous error message so we can reliably create an /// HTTP status code. Specific status codes may be provided in either case, or /// the defaults (400 for client, 500 for server) will be used. +/// +/// The default implementation will produce a server-blamed 500 error. #[derive(Debug)] -pub enum ErrorCause { +pub enum ErrorBlame { Client(Option<u16>), Server(Option<u16>), } +impl Default for ErrorBlame { + fn default() -> Self { + Self::Server(None) + } +} /// An error that has an attached cause that blames either the client or the /// server for its occurrence. You can convert any error into this with /// `.into()` or `?`, which will set the cause to the server by default, /// resulting in a *500 Internal Server Error* HTTP status code. If this isn't /// what you want, you'll need to initialize this explicitly. +/// +/// *Note for those using `anyhow`: use `.map_err(|e| anyhow::anyhow!(e))?` +/// to use anyhow in Perseus render functions.* +#[cfg(not(target_arch = "wasm32"))] #[derive(Debug)] -pub struct GenericErrorWithCause { +pub struct BlamedError<E: Send + Sync> { /// The underlying error. - pub error: Box<dyn std::error::Error + Send + Sync>, - /// The cause of the error. - pub cause: ErrorCause, + pub error: E, + /// Who is to blame for the error. + pub blame: ErrorBlame, +} +#[cfg(not(target_arch = "wasm32"))] +impl<E: std::error::Error + Send + Sync + 'static> BlamedError<E> { + /// Converts this blamed error into an internal boxed version that is + /// generic over the error type. + pub(crate) fn into_boxed(self) -> GenericBlamedError { + BlamedError { + error: Box::new(self.error), + blame: self.blame, + } + } } // We should be able to convert any error into this easily (e.g. with `?`) with // the default being to blame the server -impl<E: std::error::Error + Send + Sync + 'static> From<E> for GenericErrorWithCause { +#[cfg(not(target_arch = "wasm32"))] +impl<E: std::error::Error + Send + Sync + 'static> From<E> for BlamedError<E> { fn from(error: E) -> Self { Self { - error: error.into(), - cause: ErrorCause::Server(None), + error, + blame: ErrorBlame::default(), } } } -/// Creates a new [`GenericErrorWithCause` (the error type behind -/// [`RenderFnResultWithCause`](crate::RenderFnResultWithCause)) efficiently. -/// This allows you to explicitly return errors from any state-generation -/// functions, including both an error and a statement of whether the server or -/// the client is responsible. With this macro, you can use any of the following -/// syntaxes (substituting `"error!"` for any error that can be converted with -/// `.into()` into a `Box<dyn std::error::Error>`): -/// -/// - `blame_err!(client, "error!")` -- an error that's the client's fault, with -/// the default HTTP status code (400, a generic client error) -/// - `blame_err!(server, "error!")` -- an error that's the server's fault, with -/// the default HTTP status code (500, a generic server error) -/// - `blame_err!(client, 404, "error!")` -- an error that's the client's fault, -/// with a custom HTTP status code (404 in this example) -/// - `blame_err!(server, 501, "error!")` -- an error that's the server's fault, -/// with a custom HTTP status code (501 in this example) -/// -/// Note that this macro will automatically `return` the error it creates. -#[macro_export] -macro_rules! blame_err { - (client, $err:expr) => { - return ::std::result::Result::Err(::perseus::GenericErrorWithCause { - error: $err.into(), - cause: $crate::ErrorCause::Client(::std::option::Option::None), - }) - }; - (client, $code:literal, $err:expr) => { - return ::std::result::Result::Err(::perseus::GenericErrorWithCause { - error: $err.into(), - cause: $crate::ErrorCause::Client(::std::option::Option::Some($code)), - }) - }; - (server, $err:expr) => { - return ::std::result::Result::Err(::perseus::GenericErrorWithCause { - error: $err.into(), - cause: $crate::ErrorCause::Server(::std::option::Option::None), - }) - }; - (server, $code:literal, $err:expr) => { - return ::std::result::Result::Err(::perseus::GenericErrorWithCause { - error: $err.into(), - cause: $crate::ErrorCause::Server(::std::option::Option::Some($code)), - }) - }; -} - -/// This macro is identical to [`blame_err!`], except it will simply return a -/// [`GenericErrorWithCause`], not `return`ing it from the caller function. This -/// is more useful if you're providing a blamed error to something like -/// `.map_err()`. -#[macro_export] -macro_rules! make_blamed_err { - (client, $err:expr) => { - ::perseus::GenericErrorWithCause { - error: $err.into(), - cause: $crate::ErrorCause::Client(::std::option::Option::None), - } - }; - (client, $code:literal, $err:expr) => { - ::perseus::GenericErrorWithCause { - error: $err.into(), - cause: $crate::ErrorCause::Client(::std::option::Option::Some($code)), - } - }; - (server, $err:expr) => { - ::perseus::GenericErrorWithCause { - error: $err.into(), - cause: $crate::ErrorCause::Server(::std::option::Option::None), - } - }; - (server, $code:literal, $err:expr) => { - ::perseus::GenericErrorWithCause { - error: $err.into(), - cause: $crate::ErrorCause::Server(::std::option::Option::Some($code)), - } - }; -} +/// A simple wrapper for generic, boxed, blamed errors. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) type GenericBlamedError = BlamedError<Box<dyn std::error::Error + Send + Sync>>; diff --git a/packages/perseus/src/export.rs b/packages/perseus/src/export.rs deleted file mode 100644 index c63124546f..0000000000 --- a/packages/perseus/src/export.rs +++ /dev/null @@ -1,269 +0,0 @@ -use crate::errors::*; -use crate::i18n::{Locales, TranslationsManager}; -use crate::page_data::PageDataPartial; -use crate::server::{get_render_cfg, HtmlShell}; -use crate::stores::ImmutableStore; -use crate::template::{ArcTemplateMap, TemplateState}; -use crate::{page_data::PageData, SsrNode}; -use futures::future::{try_join, try_join_all}; - -/// Gets the static page data. -pub async fn get_static_page_data( - path: &str, - has_state: bool, - immutable_store: &ImmutableStore, -) -> Result<PageData, ServerError> { - // Get the partial HTML content and a state to go with it (if applicable) - let content = immutable_store - .read(&format!("static/{}.html", path)) - .await?; - let head = immutable_store - .read(&format!("static/{}.head.html", path)) - .await?; - let state = match has_state { - true => serde_json::from_str( - &immutable_store - .read(&format!("static/{}.json", path)) - .await?, - ) - .map_err(|err| ServerError::InvalidPageState { source: err })?, - false => TemplateState::empty().state, - }; - // Create an instance of `PageData` - Ok(PageData { - content, - state, - head, - }) -} - -/// The properties necessary to export an app. -#[derive(Debug)] -pub struct ExportProps<'a, T: TranslationsManager> { - /// All the templates in the app. - pub templates: &'a ArcTemplateMap<SsrNode>, - /// The HTML shell to use. - pub html_shell: HtmlShell, - /// The locales data for the app. - pub locales: &'a Locales, - /// An immutable store. - pub immutable_store: &'a ImmutableStore, - /// A translations manager. - pub translations_manager: &'a T, - /// The server-side path prefix/ - pub path_prefix: String, - /// A stringified global state. - pub global_state: &'a TemplateState, -} - -/// Exports your app to static files, which can be served from anywhere, without -/// needing a server. This assumes that the app has already been built, and that -/// no templates are using non-static features (which can be ensured by passing -/// `true` as the last parameter to `build_app`). -pub async fn export_app<T: TranslationsManager>( - ExportProps { - templates, - html_shell, - locales, - immutable_store, - translations_manager, - path_prefix, - global_state, - }: ExportProps<'_, T>, -) -> Result<(), ServerError> { - // The render configuration acts as a guide here, it tells us exactly what we - // need to iterate over (no request-side pages!) - let render_cfg = get_render_cfg(immutable_store).await?; - - // We can do literally everything concurrently here - let mut export_futs = Vec::new(); - // Loop over every partial - for (path, template_path) in render_cfg { - let fut = export_path( - (path.to_string(), template_path.to_string()), - templates, - locales, - &html_shell, - immutable_store, - path_prefix.to_string(), - global_state, - translations_manager, - ); - export_futs.push(fut); - } - // If we're using i18n, loop through the locales to create translations files - let mut translations_futs = Vec::new(); - if locales.using_i18n { - for locale in locales.get_all() { - let fut = create_translation_file(locale, immutable_store, translations_manager); - translations_futs.push(fut); - } - } - - try_join(try_join_all(export_futs), try_join_all(translations_futs)).await?; - - // Copying in bundles from the filesystem is left to the CLI command for - // exporting, so we're done! - - Ok(()) -} - -/// Creates a translation file for exporting. This is broken out for -/// concurrency. -pub async fn create_translation_file( - locale: &str, - immutable_store: &ImmutableStore, - translations_manager: &impl TranslationsManager, -) -> Result<(), ServerError> { - // Get the translations string for that - let translations_str = translations_manager - .get_translations_str_for_locale(locale.to_string()) - .await?; - // Write it to an asset so that it can be served directly - immutable_store - .write( - &format!("exported/.perseus/translations/{}", locale), - &translations_str, - ) - .await?; - - Ok(()) -} - -/// Exports a single path within a template. -#[allow(clippy::too_many_arguments)] -pub async fn export_path( - (path, template_path): (String, String), - templates: &ArcTemplateMap<SsrNode>, - locales: &Locales, - html_shell: &HtmlShell, - immutable_store: &ImmutableStore, - path_prefix: String, - global_state: &TemplateState, - translations_manager: &impl TranslationsManager, -) -> Result<(), ServerError> { - // We need the encoded path to reference flattened build artifacts - // But we don't create a flattened system with exporting, everything is properly - // created in a directory structure - let path_encoded = urlencoding::encode(&path).to_string(); - // All initial load pages should be written into their own folders, which - // prevents a situation of a template root page outside the directory for the - // rest of that template's pages (see #73) The `.html` file extension is - // added when this variable is used (for contrast to the `.json`s) - let initial_load_path = if path.ends_with("index") { - // However, if it's already an index page, we don't want `index/index.html` - path.to_string() - } else { - format!("{}/index", &path) - }; - - // Get the template itself - let template = templates.get(&template_path); - let template = match template { - Some(template) => template, - None => { - return Err(ServeError::PageNotFound { - path: template_path.to_string(), - } - .into()) - } - }; - // Create a locale detection file for it if we're using i18n - // These just send the app shell, which will perform a redirect as necessary - // Notably, these also include fallback redirectors if either Wasm or JS is - // disabled (or both) - if locales.using_i18n { - immutable_store - .write( - &format!("exported/{}.html", &initial_load_path), - &html_shell - .clone() - .locale_redirection_fallback(&format!( - "{}/{}/{}", - path_prefix, locales.default, &path - )) - .to_string(), - ) - .await?; - } - // Check if that template uses build state (in which case it should have a JSON - // file) - let has_state = template.uses_build_state(); - if locales.using_i18n { - // Loop through all the app's locales - for locale in locales.get_all() { - let page_data = get_static_page_data( - &format!("{}-{}", locale, &path_encoded), - has_state, - immutable_store, - ) - .await?; - // Get the translations string for this locale - let translations = translations_manager - .get_translations_str_for_locale(locale.to_string()) - .await?; - // Create a full HTML file from those that can be served for initial loads - // The build process writes these with a dummy default locale even though we're - // not using i18n - let full_html = html_shell - .clone() - .page_data(&page_data, global_state, &translations) - .to_string(); - immutable_store - .write( - &format!("exported/{}/{}.html", locale, initial_load_path), - &full_html, - ) - .await?; - - // Serialize the page data to JSON and write it as a partial (fetched by the app - // shell for subsequent loads) - let partial_page_data = PageDataPartial { - state: page_data.state, - head: page_data.head, - }; - let partial = serde_json::to_string(&partial_page_data).unwrap(); - immutable_store - .write( - &format!("exported/.perseus/page/{}/{}.json", locale, &path), - &partial, - ) - .await?; - } - } else { - let page_data = get_static_page_data( - &format!("{}-{}", locales.default, &path_encoded), - has_state, - immutable_store, - ) - .await?; - // Create a full HTML file from those that can be served for initial loads - // The build process writes these with a dummy default locale even though we're - // not using i18n - let full_html = html_shell - .clone() - .page_data(&page_data, global_state, "") - .to_string(); - // We don't add an extension because this will be queried directly by the - // browser - immutable_store - .write(&format!("exported/{}.html", initial_load_path), &full_html) - .await?; - - // Serialize the page data to JSON and write it as a partial (fetched by the app - // shell for subsequent loads) - let partial_page_data = PageDataPartial { - state: page_data.state, - head: page_data.head, - }; - let partial = serde_json::to_string(&partial_page_data).unwrap(); - immutable_store - .write( - &format!("exported/.perseus/page/{}/{}.json", locales.default, &path), - &partial, - ) - .await?; - } - - Ok(()) -} diff --git a/packages/perseus/src/i18n/client_translations_manager.rs b/packages/perseus/src/i18n/client_translations_manager.rs index 226dd0c6a6..0978349f45 100644 --- a/packages/perseus/src/i18n/client_translations_manager.rs +++ b/packages/perseus/src/i18n/client_translations_manager.rs @@ -24,12 +24,18 @@ impl ClientTranslationsManager { /// Creates a new client-side translations manager that hasn't cached /// anything yet. This needs to know about an app's supported locales so /// it can avoid network requests to unsupported locales. - pub fn new(locales: &Locales) -> Self { + pub(crate) fn new(locales: &Locales) -> Self { Self { cached_translator: Rc::new(RefCell::new(None)), locales: locales.clone(), } } + /// Gets the currently cached translator. This is designed to be used as a + /// backend for a method that works with both this and the + /// engine-side-provided translator. + pub(crate) fn get_translator(&self) -> Option<Translator> { + self.cached_translator.borrow().as_ref().cloned() + } /// An internal preflight check performed before getting a translator. This /// consists of making sure the locale is supported, and that the app is /// actually using i18n. If i18n is not being used, then this will @@ -37,18 +43,23 @@ impl ClientTranslationsManager { /// be performed. If you need to fetch a translator after calling this, then /// you should be sure to cache it. /// - /// This will also return the cached translator if possible. - fn preflight_check(&self, locale: &str) -> Result<Option<Translator>, ClientError> { + /// This will return `false` if the caller needs to take no further action + /// to set this translator, or `true` if it shoudl go ahead. + /// + /// # Panics + /// + /// This will panic if the given locale is not supported. + fn preflight_check(&self, locale: &str) -> Result<bool, ClientError> { // Check if we've already cached let mut cached_translator = self.cached_translator.borrow_mut(); if cached_translator.is_some() && cached_translator.as_ref().unwrap().get_locale() == locale { - Ok(Some(cached_translator.as_ref().unwrap().clone())) + Ok(false) } else { // Check if the locale is supported and we're actually using i18n if self.locales.is_supported(locale) && self.locales.using_i18n { // We're clear to fetch a translator for this locale - Ok(None) + Ok(true) } else if !self.locales.using_i18n { // If we aren't even using i18n, then it would be pointless to fetch // translations @@ -56,91 +67,83 @@ impl ClientTranslationsManager { // Cache that translator *cached_translator = Some(translator); // Now return that - Ok(Some(cached_translator.as_ref().unwrap().clone())) + Ok(false) } else { - Err(ClientError::LocaleNotSupported { - locale: locale.to_string(), - }) + // This is an internal total invariant (due to `match_route`) + panic!("locale not supported (this is a bug)"); } } } /// Caches the given translator internally for future use without needing to /// make network requests. - /// - /// This consumes the given translator, and then re-fetches it from the - /// cache. - fn cache_translator(&self, translator: Translator) -> Translator { - let mut cached_translator = self.cached_translator.borrow_mut(); - *cached_translator = Some(translator); - cached_translator.as_ref().unwrap().clone() + fn cache_translator(&self, translator: Translator) { + *self.cached_translator.borrow_mut() = Some(translator); } - /// Gets a `Translator` for the given locale, using the given translations + /// Caches a `Translator` for the given locale, using the given translations /// string. This is intended to be used when fetching the translations /// string from the window variable provided by the server for initial /// loads. /// - /// Note that this function automatically caches the translator it creates. - pub fn get_translator_for_translations_str( + /// # Panics + /// This will panic if the given locale is not supported. + pub(crate) fn set_translator_for_translations_str( &self, locale: &str, translations_str: &str, - ) -> Result<Translator, ClientError> { - match self.preflight_check(locale)? { - Some(translator) => Ok(translator), - // If we're clear to create the translator (i.e. it wasn't cached and the locale is - // supported), do so - None => { - let translator = - match Translator::new(locale.to_string(), translations_str.to_string()) { - Ok(translator) => translator, - Err(err) => { - return Err(FetchError::SerFailed { - url: "*".to_string(), - source: err.into(), - } - .into()) - } - }; - // This caches and returns the translator - Ok(self.cache_translator(translator)) - } + ) -> Result<(), ClientError> { + if self.preflight_check(locale)? { + let translator = match Translator::new(locale.to_string(), translations_str.to_string()) + { + Ok(translator) => translator, + Err(err) => { + return Err(FetchError::SerFailed { + url: "*".to_string(), + source: err.into(), + ty: AssetType::Translations, + } + .into()) + } + }; + self.cache_translator(translator); } + + Ok(()) } - /// Gets a `Translator` for the given locale. This will use the + /// Caches a `Translator` for the given locale. This will use the /// internally cached `Translator` if possible, and will otherwise fetch /// the translations from the server. This manages mutability for caching /// internally. - pub async fn get_translator_for_locale<'a>( + /// + /// # Panics + /// + /// This will panic if the given locale is not supported. + pub(crate) async fn set_translator_for_locale<'a>( &'a self, locale: &'a str, - ) -> Result<Translator, ClientError> { - match self.preflight_check(locale)? { - Some(translator) => Ok(translator), - // If we're clear to fetch the translator (i.e. it wasn't cached and the locale is - // supported), do so - None => { - let path_prefix = get_path_prefix_client(); - // Get the translations data - let asset_url = format!("{}/.perseus/translations/{}", path_prefix, locale); - // If this doesn't exist, then it's a 404 (we went here by explicit navigation - // after checking the locale, so that's a bug) - let translations_str = fetch(&asset_url).await?; - let translator = match translations_str { - Some(translations_str) => { - // All good, turn the translations into a translator - self.get_translator_for_translations_str(locale, &translations_str)? + ) -> Result<(), ClientError> { + if self.preflight_check(locale)? { + let path_prefix = get_path_prefix_client(); + // Get the translations data + let asset_url = format!("{}/.perseus/translations/{}", path_prefix, locale); + // If this doesn't exist, then it's a 404 (we went here by explicit navigation + // after checking the locale, so that's a bug) + let translations_str = fetch(&asset_url, AssetType::Translations).await?; + match translations_str { + Some(translations_str) => { + // All good, turn the translations into a translator + self.set_translator_for_translations_str(locale, &translations_str)? + } + // If we get a 404 for a supported locale, that's an exception + None => { + return Err(ClientInvariantError::ValidLocaleNotProvided { + locale: locale.to_string(), } - // If we get a 404 for a supported locale, that's an exception - None => { - return Err(ClientError::ValidLocaleNotProvided { - locale: locale.to_string(), - }) - } - }; - // This caches and returns the translator - Ok(self.cache_translator(translator)) - } + .into()) + } + }; } + + Ok(()) } } diff --git a/packages/perseus/src/i18n/locale_detector.rs b/packages/perseus/src/i18n/locale_detector.rs index 5cb2848e56..0c3ee4de6c 100644 --- a/packages/perseus/src/i18n/locale_detector.rs +++ b/packages/perseus/src/i18n/locale_detector.rs @@ -1,5 +1,5 @@ use super::Locales; -use crate::utils::get_path_prefix_client; +use crate::{path::PathWithoutLocale, utils::get_path_prefix_client}; use sycamore::rt::Reflect; use wasm_bindgen::JsValue; @@ -12,7 +12,7 @@ use wasm_bindgen::JsValue; /// /// Note that this does not actually redirect on its own, it merely provides an /// argument for `sycamore_router::navigate_replace()`. -pub(crate) fn detect_locale(url: String, locales: &Locales) -> String { +pub(crate) fn detect_locale(url: PathWithoutLocale, locales: &Locales) -> String { // If nothing matches, we'll use the default locale let mut locale = locales.default.clone(); diff --git a/packages/perseus/src/i18n/translations_manager.rs b/packages/perseus/src/i18n/translations_manager.rs index 6131dea243..2acef033d1 100644 --- a/packages/perseus/src/i18n/translations_manager.rs +++ b/packages/perseus/src/i18n/translations_manager.rs @@ -54,6 +54,18 @@ pub trait TranslationsManager: std::fmt::Debug + Clone + Send + Sync { &self, locale: String, ) -> Result<String, TranslationsManagerError>; + /// Gets a translator for the given locale and translations string. This is + /// intended as an internal convenience method to minimize extra computation + /// when both a translations string and a translator are required for the + /// same locale. + /// + /// **Warning:** providing a mismatched translations string and locale to + /// this function will lead to chaos. + async fn get_translator_for_translations_str( + &self, + locale: String, + translations_str: String, + ) -> Result<Translator, TranslationsManagerError>; /// Creates a new instance of this translations manager, as a dummy for apps /// that aren't using i18n at all. This may seem pointless, but it's needed /// for trait completeness and to support certain engine middleware @@ -127,7 +139,7 @@ impl FsTranslationsManager { /// have their translations read from disk on every request. If fetching /// translations for any of the given locales fails, this will panic /// (locales to be cached should always be hardcoded). - // TODO performance analysis of manual caching strategy + // TODO Performance analysis of manual caching strategy pub async fn new(root_path: String, locales_to_cache: Vec<String>, file_ext: String) -> Self { // Initialize a new instance without any caching first let mut manager = Self { @@ -246,6 +258,21 @@ impl TranslationsManager for FsTranslationsManager { Ok(translator) } + #[cfg(not(target_arch = "wasm32"))] + async fn get_translator_for_translations_str( + &self, + locale: String, + translations_str: String, + ) -> Result<Translator, TranslationsManagerError> { + let translator = Translator::new(locale.clone(), translations_str).map_err(|err| { + TranslationsManagerError::SerializationFailed { + locale: locale.clone(), + source: err.into(), + } + })?; + + Ok(translator) + } #[cfg(target_arch = "wasm32")] fn new_dummy() -> Self { Self {} @@ -264,4 +291,12 @@ impl TranslationsManager for FsTranslationsManager { ) -> Result<Translator, TranslationsManagerError> { Ok(crate::i18n::Translator::new(String::new(), String::new()).unwrap()) } + #[cfg(target_arch = "wasm32")] + async fn get_translator_for_translations_str( + &self, + _locale: String, + _translations_str: String, + ) -> Result<Translator, TranslationsManagerError> { + Ok(crate::i18n::Translator::new(String::new(), String::new()).unwrap()) + } } diff --git a/packages/perseus/src/init.rs b/packages/perseus/src/init.rs index 77a1cf697b..ead5d2c63a 100644 --- a/packages/perseus/src/init.rs +++ b/packages/perseus/src/init.rs @@ -1,30 +1,34 @@ -use crate::errors::PluginError; #[cfg(not(target_arch = "wasm32"))] -use crate::server::{get_render_cfg, HtmlShell}; -use crate::stores::ImmutableStore; -#[cfg(not(target_arch = "wasm32"))] -use crate::template::ArcTemplateMap; -#[cfg(target_arch = "wasm32")] -use crate::template::TemplateMap; +use crate::server::HtmlShell; #[cfg(not(target_arch = "wasm32"))] use crate::utils::get_path_prefix_server; use crate::{ + error_views::ErrorViews, i18n::{Locales, TranslationsManager}, plugins::{PluginAction, Plugins}, state::GlobalStateCreator, stores::MutableStore, - ErrorPages, Html, SsrNode, Template, + template::{Entity, Forever, Template}, +}; +#[cfg(target_arch = "wasm32")] +use crate::{ + error_views::{ErrorContext, ErrorPosition}, + errors::ClientError, }; +use crate::{errors::PluginError, template::Capsule}; +use crate::{stores::ImmutableStore, template::EntityMap}; use futures::Future; #[cfg(target_arch = "wasm32")] use std::marker::PhantomData; #[cfg(not(target_arch = "wasm32"))] use std::pin::Pin; -#[cfg(not(target_arch = "wasm32"))] -use std::sync::Arc; -use std::{collections::HashMap, panic::PanicInfo, rc::Rc}; +#[cfg(target_arch = "wasm32")] +use std::rc::Rc; +use std::{any::TypeId, sync::Arc}; +use std::{collections::HashMap, panic::PanicInfo}; use sycamore::prelude::Scope; use sycamore::utils::hydrate::with_no_hydration_context; +use sycamore::web::{Html, SsrNode}; use sycamore::{ prelude::{component, view}, view::View, @@ -56,14 +60,14 @@ static DFLT_PSS_MAX_SIZE: usize = 25; /// If this stores a full translations manager though, it will store it as a /// `Future`, which is later evaluated. #[cfg(not(target_arch = "wasm32"))] -enum Tm<T: TranslationsManager> { +pub(crate) enum Tm<T: TranslationsManager> { Dummy(T), Full(Pin<Box<dyn Future<Output = T>>>), } #[cfg(not(target_arch = "wasm32"))] impl<T: TranslationsManager> std::fmt::Debug for Tm<T> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Tm").finish() + f.debug_struct("Tm").finish_non_exhaustive() } } @@ -93,67 +97,124 @@ where /// The options for constructing a Perseus app. This `struct` will tie /// together all your code, declaring to Perseus where your templates, /// error pages, static content, etc. are. +/// +/// # Memory leaks +/// +/// This `struct` internally stores all templates and capsules as static +/// references, since they will definitionally be required for the lifetime +/// of the app, and since this enables support for capsules created and +/// managed through a `lazy_static!`, a very convenient and efficient pattern. +/// +/// However, this does mean that the methods on this `struct` for adding +/// templates and capsules perform `Box::leak` calls internally, creating +/// deliberate memory leaks. This would be ... pub struct PerseusAppBase<G: Html, M: MutableStore, T: TranslationsManager> { /// The HTML ID of the root `<div>` element into which Perseus will be /// injected. - root: String, - /// A list of function that produce templates for the app to use. These are - /// stored as functions so that they can be called an arbitrary number of - /// times. - #[cfg(target_arch = "wasm32")] - templates: TemplateMap<G>, - #[cfg(not(target_arch = "wasm32"))] - templates: ArcTemplateMap<G>, + pub(crate) root: String, + /// A list of all the templates and capsules that the app uses. + pub(crate) entities: EntityMap<G>, /// The app's error pages. #[cfg(target_arch = "wasm32")] - error_pages: Rc<ErrorPages<G>>, + pub(crate) error_views: Rc<ErrorViews<G>>, #[cfg(not(target_arch = "wasm32"))] - error_pages: Arc<ErrorPages<G>>, + pub(crate) error_views: Arc<ErrorViews<G>>, /// The maximum size for the page state store. - pss_max_size: usize, + pub(crate) pss_max_size: usize, /// The global state creator for the app. // This is wrapped in an `Arc` so we can pass it around on the engine-side (which is solely for // Actix's benefit...) #[cfg(not(target_arch = "wasm32"))] - global_state_creator: Arc<GlobalStateCreator>, + pub(crate) global_state_creator: Arc<GlobalStateCreator>, /// The internationalization information for the app. - locales: Locales, + pub(crate) locales: Locales, /// The static aliases the app serves. #[cfg(not(target_arch = "wasm32"))] - static_aliases: HashMap<String, String>, + pub(crate) static_aliases: HashMap<String, String>, /// The plugins the app uses. - plugins: Rc<Plugins<G>>, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) plugins: Arc<Plugins>, + #[cfg(target_arch = "wasm32")] + pub(crate) plugins: Rc<Plugins>, /// The app's immutable store. #[cfg(not(target_arch = "wasm32"))] - immutable_store: ImmutableStore, + pub(crate) immutable_store: ImmutableStore, /// The HTML template that'll be used to render the app into. This must be /// static, but can be generated or sourced in any way. Note that this MUST /// contain a `<div>` with the `id` set to whatever the value of `self.root` /// is. - index_view: String, + pub(crate) index_view: String, /// The app's mutable store. #[cfg(not(target_arch = "wasm32"))] - mutable_store: M, + pub(crate) mutable_store: M, /// The app's translations manager, expressed as a function yielding a /// `Future`. This is only ever needed on the server-side, and can't be set /// up properly on the client-side because we can't use futures in the /// app initialization in Wasm. #[cfg(not(target_arch = "wasm32"))] - translations_manager: Tm<T>, + pub(crate) translations_manager: Tm<T>, /// The location of the directory to use for static assets that will placed /// under the URL `/.perseus/static/`. By default, this is the `static/` /// directory at the root of your project. Note that the directory set /// here will only be used if it exists. #[cfg(not(target_arch = "wasm32"))] - static_dir: String, - /// A handler for panics on the client-side. This could create an arbitrary - /// message for the user, or do anything else. + pub(crate) static_dir: String, + /// A handler for panics on the browser-side. + #[cfg(target_arch = "wasm32")] + pub(crate) panic_handler: Option<Box<dyn Fn(&PanicInfo) + Send + Sync + 'static>>, + /// A duplicate of the app's error handling function intended for panic + /// handling. This must be extracted as an owned value and provided in a + /// thread-safe manner to the panic hook system. + /// + /// This is in an `Arc` because panic hooks are `Fn`s, not `FnOnce`s. #[cfg(target_arch = "wasm32")] - panic_handler: Option<Box<dyn Fn(&PanicInfo) + Send + Sync + 'static>>, + pub(crate) panic_handler_view: Arc< + dyn Fn(Scope, ClientError, ErrorContext, ErrorPosition) -> (View<SsrNode>, View<G>) + + Send + + Sync, + >, // We need this on the client-side to account for the unused type parameters #[cfg(target_arch = "wasm32")] _marker: PhantomData<(M, T)>, } +impl<G: Html, M: MutableStore, T: TranslationsManager> std::fmt::Debug for PerseusAppBase<G, M, T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // We have to do the commons, and then the target-gates separately (otherwise + // Rust uses the dummy methods) + let mut debug = f.debug_struct("PerseusAppBase"); + debug + .field("root", &self.root) + .field("entities", &self.entities) + .field("error_views", &self.error_views) + .field("pss_max_size", &self.pss_max_size) + .field("locale", &self.locales) + .field("plugins", &self.plugins) + .field("index_view", &self.index_view); + #[cfg(target_arch = "wasm32")] + { + return debug + .field( + "panic_handler", + &self + .panic_handler + .as_ref() + .map(|_| "dyn Fn(&PanicInfo) + Send + Sync + 'static"), + ) + .finish_non_exhaustive(); + } + #[cfg(not(target_arch = "wasm32"))] + { + return debug + .field("global_state_creator", &self.global_state_creator) + .field("mutable_store", &self.mutable_store) + .field("translations_manager", &self.translations_manager) + .field("static_dir", &self.static_dir) + .field("static_aliases", &self.static_aliases) + .field("immutable_store", &self.immutable_store) + .finish_non_exhaustive(); + } + } +} // The usual implementation in which the default mutable store is used // We don't need to have a similar one for the default translations manager @@ -273,10 +334,10 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { root: "root".to_string(), // We do initialize with no templates, because an app without templates is in theory // possible (and it's more convenient to call `.template()` for each one) - templates: HashMap::new(), - // We do offer default error pages, but they'll panic if they're called for production + entities: HashMap::new(), + // We do offer default error views, but they'll panic if they're called for production // building - error_pages: Default::default(), + error_views: Default::default(), pss_max_size: DFLT_PSS_MAX_SIZE, #[cfg(not(target_arch = "wasm32"))] global_state_creator: Arc::new(GlobalStateCreator::default()), @@ -291,6 +352,9 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { #[cfg(not(target_arch = "wasm32"))] static_aliases: HashMap::new(), // By default, we won't use any plugins + #[cfg(not(target_arch = "wasm32"))] + plugins: Arc::new(Plugins::new()), + #[cfg(target_arch = "wasm32")] plugins: Rc::new(Plugins::new()), #[cfg(not(target_arch = "wasm32"))] immutable_store: ImmutableStore::new("./dist".to_string()), @@ -305,6 +369,8 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { #[cfg(target_arch = "wasm32")] panic_handler: None, #[cfg(target_arch = "wasm32")] + panic_handler_view: ErrorViews::unlocalized_development_default().take_panic_handler(), + #[cfg(target_arch = "wasm32")] _marker: PhantomData, } } @@ -317,10 +383,10 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { root: "root".to_string(), // We do initialize with no templates, because an app without templates is in theory // possible (and it's more convenient to call `.template()` for each one) - templates: HashMap::new(), + entities: HashMap::new(), // We do offer default error pages, but they'll panic if they're called for production // building - error_pages: Default::default(), + error_views: Default::default(), pss_max_size: DFLT_PSS_MAX_SIZE, // By default, we'll disable i18n (as much as I may want more websites to support more // languages...) @@ -334,6 +400,7 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { // Many users won't need anything fancy in the index view, so we provide a default index_view: DFLT_INDEX_VIEW.to_string(), panic_handler: None, + panic_handler_view: ErrorViews::unlocalized_development_default().take_panic_handler(), _marker: PhantomData, } } @@ -361,43 +428,132 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { } self } - /// Sets all the app's templates. This takes a vector of boxed functions - /// that return templates. + /// Sets all the app's templates. This takes a vector of templates. /// /// Usually, it's preferred to run `.template()` once for each template, /// rather than manually constructing this more inconvenient type. pub fn templates(mut self, val: Vec<Template<G>>) -> Self { for template in val.into_iter() { - #[cfg(target_arch = "wasm32")] - self.templates - .insert(template.get_path(), Rc::new(template)); - #[cfg(not(target_arch = "wasm32"))] - self.templates - .insert(template.get_path(), Arc::new(template)); + self = self.template(template); } self } - /// Adds a single new template to the app (convenience function). This takes - /// a *function that returns a template* (for internal reasons). + /// Adds a single new template to the app. This expects the output of + /// a function that is generic over `G: Html`. If you have something with a + /// predetermined type, like a `lazy_static!` that's using + /// `PerseusNodeType`, you should use `.template_ref()` instead. For + /// more information on the differences between the function and referrence + /// patterns, see the book. /// /// See [`Template`] for further details. - pub fn template(mut self, val: Template<G>) -> Self { - #[cfg(target_arch = "wasm32")] - self.templates.insert(val.get_path(), Rc::new(val)); - #[cfg(not(target_arch = "wasm32"))] - self.templates.insert(val.get_path(), Arc::new(val)); + pub fn template(self, val: impl Into<Forever<Template<G>>>) -> Self { + self.template_ref(val) + } + /// Adds a single new template to the app. This can accept either an owned + /// [`Template`] or a static reference to one, as might be created by a + /// `lazy_static!`. The latter would force you to specify the rendering + /// backend type (`G`) manually, using a smart alias like + /// `PerseusNodeType`. This method performs internal type coercions to make + /// statics work neatly. + /// + /// If your templates come from functions like `get_template`, that are + /// generic over `G: Html`, you can use `.template()`, to avoid having + /// to specify `::<G>` manually. + /// + /// See [`Template`] for further details, and the book for further details + /// on the differences between the function and reference patterns. + pub fn template_ref<H: Html>(mut self, val: impl Into<Forever<Template<H>>>) -> Self { + assert_eq!( + TypeId::of::<G>(), + TypeId::of::<H>(), + "mismatched render backends" + ); + let val = val.into(); + // SAFETY: We asserted that `G == H` above. + let val: Forever<Template<G>> = unsafe { std::mem::transmute(val) }; + + let entity: Forever<Entity<G>> = match val { + Forever::Owned(capsule) => capsule.inner.into(), + Forever::StaticRef(capsule_ref) => (&capsule_ref.inner).into(), + }; + + let path = entity.get_path(); + self.entities.insert(path, entity); + self + } + // TODO + // /// Sets all the app's capsules. This takes a vector of capsules. + // /// + // /// Usually, it's preferred to run `.capsule()` once for each capsule, + // /// rather than manually constructing this more inconvenient type. + // pub fn capsules(mut self, val: Vec<Capsule<G>>) -> Self { + // for capsule in val.into_iter() { + // self = self.capsule(capsule); + // } + // self + // } + /// Adds a single new capsule to the app. Like `.template()`, this expects + /// the output of a function that is generic over `G: Html`. If you have + /// something with a predetermined type, like a `lazy_static!` that's + /// using `PerseusNodeType`, you should use `.capsule_ref()` + /// instead. For more information on the differences between the function + /// and reference patterns, see the book. + /// + /// See [`Capsule`] for further details. + pub fn capsule<P: Clone + 'static>(self, val: impl Into<Forever<Capsule<G, P>>>) -> Self { + self.capsule_ref(val) + } + /// Adds a single new capsule to the app. This behaves like + /// `.template_ref()`, but for capsules. + /// + /// See [`Capsule`] for further details. + pub fn capsule_ref<H: Html, P: Clone + 'static>( + mut self, + val: impl Into<Forever<Capsule<H, P>>>, + ) -> Self { + assert_eq!( + TypeId::of::<G>(), + TypeId::of::<H>(), + "mismatched render backends" + ); + let val = val.into(); + // Enforce that capsules must have defined fallbacks + if val.fallback.is_none() { + panic!( + "capsule '{}' has no fallback (please register one)", + val.inner.get_path() + ) + } + + // SAFETY: We asserted that `G == H` above. + let val: Forever<Capsule<G, P>> = unsafe { std::mem::transmute(val) }; + + let entity: Forever<Entity<G>> = match val { + Forever::Owned(capsule) => capsule.inner.into(), + Forever::StaticRef(capsule_ref) => (&capsule_ref.inner).into(), + }; + + let path = entity.get_path(); + self.entities.insert(path, entity); self } - /// Sets the app's error pages. See [`ErrorPages`] for further details. - pub fn error_pages(mut self, val: ErrorPages<G>) -> Self { + /// Sets the app's error views. See [`ErrorViews`] for further details. + // Internally, this will extract a copy of the main handler for panic + // usage. Note that the default value of this is extracted from the default + // error views. + #[allow(unused_mut)] + pub fn error_views(mut self, mut val: ErrorViews<G>) -> Self { #[cfg(target_arch = "wasm32")] { - self.error_pages = Rc::new(val); + let panic_handler = val.take_panic_handler(); + self.error_views = Rc::new(val); + self.panic_handler_view = panic_handler; } #[cfg(not(target_arch = "wasm32"))] { - self.error_pages = Arc::new(val); + self.error_views = Arc::new(val); } + self } /// Sets the app's [`GlobalStateCreator`]. @@ -509,8 +665,16 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { } /// Sets the plugins that the app will use. See [`Plugins`] for /// further details. - pub fn plugins(mut self, val: Plugins<G>) -> Self { - self.plugins = Rc::new(val); + pub fn plugins(mut self, val: Plugins) -> Self { + #[cfg(target_arch = "wasm32")] + { + self.plugins = Rc::new(val); + } + #[cfg(not(target_arch = "wasm32"))] + { + self.plugins = Arc::new(val); + } + self } /// Sets the [`MutableStore`] for the app to use, which you would change for @@ -581,14 +745,27 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { /// Sets the browser-side panic handler for your app. This is a function /// that will be executed if your app panics (which should never be caused /// by Perseus unless something is seriously wrong, it's much more likely - /// to come from your code, or third-party code). If this happens, your page - /// would become totally uninteractive, with no warning to the user, since - /// Wasm will simply abort. In such cases, it is strongly recommended to - /// generate a warning message that notifies the user. + /// to come from your code, or third-party code). + /// + /// In the case of a panic, Perseus will automatically try to render a full + /// popup error to explain the situation to the user before terminating, + /// but, since it's impossible to use the plugins in the case of a + /// panic, this function is provided as an alternative in case you want + /// to perform other work, like sending a crash report. + /// + /// This function **must not** panic itself, because Perseus renders the + /// message *after* your handler is executed. If it panics, that popup + /// will never get to the user, leading to very poor UX. That said, + /// don't stress about calling things like `web_sys::window().unwrap()`, + /// because, if that fails, then trying to render a popup will + /// *definitely* fail anyway. Perseus will attempt to write an error + /// message to the console before this, just in case anything panics. /// /// Note that there is no access within this function to Sycamore, page /// state, global state, or translators. Assume that your code has - /// completely imploded when you write this function. + /// completely imploded when you write this function. Anything more advanced + /// should be left to your error views system, when it handles + /// `ClientError::Panic`. /// /// This has no default value. #[allow(unused_variables)] @@ -613,13 +790,13 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { .unwrap_or_else(|| self.root.to_string()); Ok(root) } - /// Gets the directory containing static assets to be hosted under the URL - /// `/.perseus/static/`. - // TODO Plugin action for this? - #[cfg(not(target_arch = "wasm32"))] - pub fn get_static_dir(&self) -> String { - self.static_dir.to_string() - } + // /// Gets the directory containing static assets to be hosted under the URL + // /// `/.perseus/static/`. + // // TODO Plugin action for this? + // #[cfg(not(target_arch = "wasm32"))] + // pub fn get_static_dir(&self) -> String { + // self.static_dir.to_string() + // } /// Gets the index view as a string, without generating an HTML shell (pass /// this into `::get_html_shell()` to do that). /// @@ -640,23 +817,15 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { /// is, it's necessitated, otherwise exporting would try to access the built /// app before it had actually been built. #[cfg(not(target_arch = "wasm32"))] - pub async fn get_html_shell( + pub(crate) async fn get_html_shell( index_view_str: String, root: &str, - immutable_store: &ImmutableStore, - plugins: &Plugins<G>, + render_cfg: &HashMap<String, String>, + plugins: &Plugins, ) -> Result<HtmlShell, PluginError> { // Construct an HTML shell - let mut html_shell = HtmlShell::new( - index_view_str, - root, - // TODO Handle this properly (good enough for now because that's what we were already - // doing) - &get_render_cfg(immutable_store) - .await - .expect("Couldn't get render configuration!"), - &get_path_prefix_server(), - ); + let mut html_shell = + HtmlShell::new(index_view_str, root, render_cfg, &get_path_prefix_server()); // Apply the myriad plugin actions to the HTML shell (replacing the whole thing // first if need be) @@ -733,74 +902,32 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { Ok(html_shell) } - /// Gets the templates in an `Rc`-based `HashMap` for non-concurrent access. - #[cfg(target_arch = "wasm32")] - pub fn get_templates_map(&self) -> Result<TemplateMap<G>, PluginError> { - // One the browser-side, this is already a `TemplateMap` internally - let mut map = self.templates.clone(); - - // This will return a map of plugin name to a vector of templates to add - let extra_templates = self - .plugins - .functional_actions - .settings_actions - .add_templates - .run((), self.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 { - map.insert(template.get_path(), Rc::new(template)); - } - } - - Ok(map) - } - /// Gets the templates in an `Arc`-based `HashMap` for concurrent access. - /// This should only be relevant on the server-side. - #[cfg(not(target_arch = "wasm32"))] - pub fn get_atomic_templates_map(&self) -> Result<ArcTemplateMap<G>, PluginError> { - // One the engine-side, this is already an `ArcTemplateMap` internally - let mut map = self.templates.clone(); - - // This will return a map of plugin name to a vector of templates to add - let extra_templates = self - .plugins - .functional_actions - .settings_actions - .add_templates - .run((), self.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 { - map.insert(template.get_path(), Arc::new(template)); - } - } - - Ok(map) - } - /// Gets the [`ErrorPages`] used in the app. This returns an `Rc`. - #[cfg(target_arch = "wasm32")] - pub fn get_error_pages(&self) -> Rc<ErrorPages<G>> { - self.error_pages.clone() - } - /// Gets the [`ErrorPages`] used in the app. This returns an `Arc`. - #[cfg(not(target_arch = "wasm32"))] - pub fn get_atomic_error_pages(&self) -> Arc<ErrorPages<G>> { - self.error_pages.clone() - } - /// Gets the maximum number of pages that can be stored in the page state - /// store before the oldest are evicted. - pub fn get_pss_max_size(&self) -> usize { - self.pss_max_size - } - /// Gets the [`GlobalStateCreator`]. This can't be directly modified by - /// plugins because of reactive type complexities. - #[cfg(not(target_arch = "wasm32"))] - pub fn get_global_state_creator(&self) -> Arc<GlobalStateCreator> { - self.global_state_creator.clone() - } + // /// Gets the map of entities (i.e. templates and capsules combined). + // pub fn get_entities_map(&self) -> EntityMap<G> { + // // This is cheap to clone + // self.entities.clone() + // } + // /// Gets the [`ErrorViews`] used in the app. This returns an `Rc`. + // #[cfg(target_arch = "wasm32")] + // pub fn get_error_views(&self) -> Rc<ErrorViews<G>> { + // self.error_views.clone() + // } + // /// Gets the [`ErrorViews`] used in the app. This returns an `Arc`. + // #[cfg(not(target_arch = "wasm32"))] + // pub fn get_atomic_error_views(&self) -> Arc<ErrorViews<G>> { + // self.error_views.clone() + // } + // /// Gets the maximum number of pages that can be stored in the page state + // /// store before the oldest are evicted. + // pub fn get_pss_max_size(&self) -> usize { + // self.pss_max_size + // } + // /// Gets the [`GlobalStateCreator`]. This can't be directly modified by + // /// plugins because of reactive type complexities. + // #[cfg(not(target_arch = "wasm32"))] + // pub fn get_global_state_creator(&self) -> Arc<GlobalStateCreator> { + // self.global_state_creator.clone() + // } /// Gets the locales information. pub fn get_locales(&self) -> Result<Locales, PluginError> { let locales = self.locales.clone(); @@ -838,19 +965,23 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { .unwrap_or(immutable_store); Ok(immutable_store) } - /// Gets the [`MutableStore`]. This can't be modified by plugins due to - /// trait complexities, so plugins should instead expose a function that - /// the user can use to manually set it. - #[cfg(not(target_arch = "wasm32"))] - pub fn get_mutable_store(&self) -> M { - self.mutable_store.clone() - } - /// Gets the plugins registered for the app. These are passed around and - /// used in a way that doesn't require them to be concurrently accessible, - /// and so are provided in an `Rc`. - pub fn get_plugins(&self) -> Rc<Plugins<G>> { - self.plugins.clone() - } + // /// Gets the [`MutableStore`]. This can't be modified by plugins due to + // /// trait complexities, so plugins should instead expose a function that + // /// the user can use to manually set it. + // #[cfg(not(target_arch = "wasm32"))] + // pub fn get_mutable_store(&self) -> M { + // self.mutable_store.clone() + // } + // /// Gets the plugins registered for the app. + // #[cfg(not(target_arch = "wasm32"))] + // pub fn get_plugins(&self) -> Arc<Plugins> { + // self.plugins.clone() + // } + // /// Gets the plugins registered for the app. + // #[cfg(target_arch = "wasm32")] + // pub fn get_plugins(&self) -> Rc<Plugins> { + // self.plugins.clone() + // } /// Gets the static aliases. This will check all provided resource paths to /// ensure they don't reference files outside the project directory, due to /// potential security risks in production (we don't want to @@ -902,13 +1033,30 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> { Ok(scoped_static_aliases) } - /// Takes the user-set panic handler out and returns it as an owned value, - /// allowing it to be used as an actual panic hook. + /// Takes the user-set panic handlers out and returns them as an owned + /// tuple, allowing them to be used in an actual panic hook. + /// + /// # Future panics + /// If this is called more than once, the view panic handler will panic when + /// called. #[cfg(target_arch = "wasm32")] - pub fn take_panic_handler( + pub fn take_panic_handlers( &mut self, - ) -> Option<Box<dyn Fn(&PanicInfo) + Send + Sync + 'static>> { - self.panic_handler.take() + ) -> ( + Option<Box<dyn Fn(&PanicInfo) + Send + Sync + 'static>>, + Arc< + dyn Fn(Scope, ClientError, ErrorContext, ErrorPosition) -> (View<SsrNode>, View<G>) + + Send + + Sync, + >, + ) { + let panic_handler_view = std::mem::replace( + &mut self.panic_handler_view, + Arc::new(|_, _, _, _| unreachable!()), + ); + let general_panic_handler = self.panic_handler.take(); + + (general_panic_handler, panic_handler_view) } } diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index da131d521e..37f58926fa 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -19,15 +19,15 @@ documentation, and this should mostly be used as a secondary reference source. Y */ #![deny(missing_docs)] -// #![deny(missing_debug_implementations)] // TODO Pending sycamore-rs/sycamore#412 +#![deny(missing_debug_implementations)] #![recursion_limit = "256"] // TODO Do we need this anymore? /// Utilities for working with the engine-side, particularly with regards to /// setting up the entrypoint for your app's build/export/server processes. #[cfg(not(target_arch = "wasm32"))] pub mod engine; -/// Utilities surrounding [`ErrorPages`] and their management. -pub mod error_pages; +/// Utilities surrounding `ErrorViews` and their management. +pub mod error_views; pub mod errors; /// Utilities for internationalization, the process of making your app available /// in multiple languages. @@ -52,15 +52,19 @@ pub mod template; /// General utilities that may be useful while building Perseus apps. pub mod utils; -#[cfg(not(target_arch = "wasm32"))] -mod build; #[cfg(all(feature = "client-helpers", target_arch = "wasm32"))] mod client; -#[cfg(not(target_arch = "wasm32"))] -mod export; mod init; mod page_data; +/// Utilities for working with typed paths. +pub mod path; +/// The core of the Perseus browser-side system. This is used on the engine-side +/// as well for rendering. +pub mod reactor; mod translator; +/// The core of the Perseus state generation system. +#[cfg(not(target_arch = "wasm32"))] +pub mod turbine; // The rest of this file is devoted to module structuring // Re-exports @@ -68,9 +72,7 @@ mod translator; pub use http; #[cfg(not(target_arch = "wasm32"))] pub use http::Request as HttpRequest; -pub use sycamore_futures::spawn_local_scoped; -#[cfg(target_arch = "wasm32")] -pub use wasm_bindgen_futures::spawn_local; + /// All HTTP requests use empty bodies for simplicity of passing them around. /// They'll never need payloads (value in path requested). /// @@ -85,24 +87,8 @@ pub type Request = HttpRequest<()>; pub type Request = (); #[cfg(feature = "macros")] -pub use perseus_macro::{ - browser, browser_main, browser_only_fn, engine, engine_main, engine_only_fn, main, main_export, - template, template_rx, test, ReactiveState, UnreactiveState, -}; -pub use sycamore::prelude::{DomNode, Html, HydrateNode, SsrNode}; -pub use sycamore_router::{navigate, navigate_replace}; +pub use perseus_macro::*; -// All the items that should be available at the top-level for convenience -pub use crate::{ - error_pages::ErrorPages, - errors::{ErrorCause, GenericErrorWithCause}, - init::*, - state::{RxResult, RxResultRef, SerdeInfallible}, - template::{ - BuildPaths, RenderCtx, RenderFnResult, RenderFnResultWithCause, StateGeneratorInfo, - Template, - }, -}; // Browser-side only #[cfg(target_arch = "wasm32")] pub use crate::utils::checkpoint; @@ -113,7 +99,6 @@ pub use client::{run_client, ClientReturn}; #[cfg(not(target_arch = "wasm32"))] pub mod internal { pub use crate::page_data::*; - pub use crate::{build::*, export::*}; } /// Internal utilities for logging. These are just re-exports so that users /// don't have to have `web_sys` and `wasm_bindgen` to use `web_log!`. @@ -124,22 +109,59 @@ pub mod log { pub use web_sys::console::log_1 as log_js_value; } +/// An alias for `DomNode`, `HydrateNode`, or `SsrNode`, depending on the +/// `hydrate` feature flag and compilation target. +/// +/// You **should not** use this in your return types (e.g. +/// `View<PerseusNodeType>`), there you should use a `G: Html` generic. +/// This is intended for `lazy_static!`s and the like, for capsules. See +/// the book and capsule examples for further details. +#[cfg(not(target_arch = "wasm32"))] +pub type PerseusNodeType = sycamore::web::SsrNode; +/// An alias for `DomNode`, `HydrateNode`, or `SsrNode`, depending on the +/// `hydrate` feature flag and compilation target. +/// +/// You **should not** use this in your return types (e.g. +/// `View<PerseusNodeType>`), there you should use a `G: Html` generic. +/// This is intended for `lazy_static!`s and the like, for capsules. See +/// the book and capsule examples for further details. +#[cfg(all(target_arch = "wasm32", not(feature = "hydrate")))] +pub type PerseusNodeType = sycamore::web::DomNode; +/// An alias for `DomNode`, `HydrateNode`, or `SsrNode`, depending on the +/// `hydrate` feature flag and compilation target. +/// +/// You **should not** use this in your return types (e.g. +/// `View<PerseusNodeType>`), there you should use a `G: Html` generic. +/// This is intended for `lazy_static!`s and the like, for capsules. See +/// the book and capsule examples for further details. +#[cfg(all(target_arch = "wasm32", feature = "hydrate"))] +pub type PerseusNodeType = sycamore::web::HydrateNode; + /// A series of imports needed by most Perseus apps, in some form. This should /// be used in conjunction with the Sycamore prelude. pub mod prelude { + pub use crate::error_views::ErrorViews; + // Target-gating doesn't matter, because the prelude is intended to be used all + // at once + #[cfg(not(target_arch = "wasm32"))] + pub use crate::errors::{BlamedError, ErrorBlame}; + pub use crate::init::*; + pub use crate::reactor::Reactor; + pub use crate::state::{BuildPaths, RxResult, RxResultRx, SerdeInfallible, StateGeneratorInfo}; + pub use crate::template::{Capsule, Template}; + pub use sycamore::web::Html; + pub use sycamore_router::{navigate, navigate_replace}; + #[cfg(not(target_arch = "wasm32"))] pub use crate::utils::{cache_fallible_res, cache_res}; pub use crate::web_log; - pub use crate::{ - blame_err, make_blamed_err, BuildPaths, ErrorCause, ErrorPages, GenericErrorWithCause, - PerseusApp, PerseusRoot, RenderCtx, RenderFnResult, RenderFnResultWithCause, Request, - RxResult, RxResultRef, SerdeInfallible, StateGeneratorInfo, Template, - }; #[cfg(feature = "macros")] pub use crate::{ - browser, browser_main, browser_only_fn, engine, engine_main, engine_only_fn, main, - main_export, template, template_rx, test, ReactiveState, UnreactiveState, + auto_scope, browser, browser_main, browser_only_fn, engine, engine_main, engine_only_fn, + main, main_export, template_rx, test, ReactiveState, UnreactiveState, }; #[cfg(any(feature = "translator-fluent", feature = "translator-lightweight"))] pub use crate::{link, t}; + pub use crate::{PerseusNodeType, Request}; + pub use sycamore_futures::spawn_local_scoped; } diff --git a/packages/perseus/src/page_data.rs b/packages/perseus/src/page_data.rs index 319d924fa4..1bf1994e6b 100644 --- a/packages/perseus/src/page_data.rs +++ b/packages/perseus/src/page_data.rs @@ -1,5 +1,7 @@ +use crate::{error_views::ServerErrorData, path::PathMaybeWithLocale}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashMap; /// Represents the data necessary to render a page, including document metadata. #[derive(Serialize, Deserialize, Debug, Clone)] @@ -8,6 +10,14 @@ pub struct PageData { pub content: String, /// The state for hydration. pub state: Value, + /// The states of all the widgets involved in rendering this page. This will + /// not include the states of delayed widgets. Each state here is fallible + /// with a client error, since any errors in widgets will simply affect + /// their own load, not that of the wider page. + /// + /// This is a map of widget path to capsule name and state, preventing the + /// need to run route resolution algorithms on the browser-side. + pub widget_states: HashMap<PathMaybeWithLocale, Result<Value, ServerErrorData>>, /// The string to interpolate into the document's `<head>`. pub head: String, } diff --git a/packages/perseus/src/path.rs b/packages/perseus/src/path.rs new file mode 100644 index 0000000000..c4ab48ecdb --- /dev/null +++ b/packages/perseus/src/path.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; +use std::ops::Deref; + +/// A path that includes no locale, and no template/capsule name. For the page +/// `/posts/foo` generated by the `posts` template, the [`PurePath`] would be +/// `foo`. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PurePath(pub String); +impl Deref for PurePath { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +/// A path that includes the locale, whether the app is using i18n or not. +/// If i18n is not being used, the locale will be `xx-XX`. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PathWithLocale(pub String); +impl Deref for PathWithLocale { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +/// A path that does not include the locale, even if the app is using i18n. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PathWithoutLocale(pub String); +impl Deref for PathWithoutLocale { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +/// A path that will include the locale if the app is using i18n, and that will +/// not if it isn't. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PathMaybeWithLocale(pub String); +impl Deref for PathMaybeWithLocale { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl PathMaybeWithLocale { + /// Creates a new instance of [`PathMaybeWithLocale`] from the given + /// [`PathWithoutLocale`] and locale. + pub fn new(path_without_locale: &PathWithoutLocale, locale: &str) -> Self { + Self(match locale { + "xx-XX" => path_without_locale.to_string(), + locale => format!("{}/{}", locale, path_without_locale.0), + }) + } +} diff --git a/packages/perseus/src/plugins/action.rs b/packages/perseus/src/plugins/action.rs index fd5625668e..1680d89ef7 100644 --- a/packages/perseus/src/plugins/action.rs +++ b/packages/perseus/src/plugins/action.rs @@ -8,15 +8,18 @@ use crate::errors::PluginError; // A: some stuff the specific action gets // dyn Any + Send: the plugin options // R: the return type -pub type Runner<A, R> = - Box<dyn Fn(&A, &(dyn Any + Send)) -> Result<R, Box<dyn std::error::Error>> + Send>; +pub type Runner<A, R> = Box< + dyn Fn(&A, &(dyn Any + Send + Sync)) -> Result<R, Box<dyn std::error::Error + Send + Sync>> + + Send + + Sync, +>; /// A trait for the interface for a plugin action, which abstracts whether it's /// a functional or a control action. /// /// `R2` here denotes the return type of the entire plugin series. For instance, /// functional plugins return a `HashMap` of the results of each plugin. -pub trait PluginAction<A, R, R2>: Send { +pub trait PluginAction<A, R, R2>: Send + Sync { /// Runs the action. This takes data that the action should expect, along /// with a map of plugins to their data. /// @@ -29,7 +32,7 @@ pub trait PluginAction<A, R, R2>: Send { fn run( &self, action_data: A, - plugin_data: &HashMap<String, Box<dyn Any + Send>>, + plugin_data: &HashMap<String, Box<dyn Any + Send + Sync>>, ) -> Result<R2, PluginError>; /// Registers a plugin that takes this action. /// @@ -42,7 +45,10 @@ pub trait PluginAction<A, R, R2>: Send { fn register_plugin( &mut self, name: &str, - runner: impl Fn(&A, &(dyn Any + Send)) -> Result<R, Box<dyn std::error::Error>> + Send + 'static, + runner: impl Fn(&A, &(dyn Any + Send + Sync)) -> Result<R, Box<dyn std::error::Error + Send + Sync>> + + Send + + Sync + + 'static, ); /// Same as `.register_plugin()`, but takes a prepared runner in a `Box`. fn register_plugin_box(&mut self, name: &str, runner: Runner<A, R>); diff --git a/packages/perseus/src/plugins/control.rs b/packages/perseus/src/plugins/control.rs index 161bed507c..1cb1c687f5 100644 --- a/packages/perseus/src/plugins/control.rs +++ b/packages/perseus/src/plugins/control.rs @@ -19,7 +19,7 @@ impl<A, R> PluginAction<A, R, Option<R>> for ControlPluginAction<A, R> { fn run( &self, action_data: A, - plugin_data: &HashMap<String, Box<dyn Any + Send>>, + plugin_data: &HashMap<String, Box<dyn Any + Send + Sync>>, ) -> Result<Option<R>, PluginError> { // If no runner is defined, this won't have any effect (same as functional // actions with no registered runners) @@ -47,7 +47,10 @@ impl<A, R> PluginAction<A, R, Option<R>> for ControlPluginAction<A, R> { fn register_plugin( &mut self, name: &str, - runner: impl Fn(&A, &(dyn Any + Send)) -> Result<R, Box<dyn std::error::Error>> + Send + 'static, + runner: impl Fn(&A, &(dyn Any + Send + Sync)) -> Result<R, Box<dyn std::error::Error + Send + Sync>> + + Send + + Sync + + 'static, ) { self.register_plugin_box(name, Box::new(runner)) } @@ -79,8 +82,7 @@ impl<A, R> std::fmt::Debug for ControlPluginAction<A, R> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ControlPluginAction") .field("controller_name", &self.controller_name) - .field("runner", &self.runner.as_ref().map(|_| "Runner")) - .finish() + .finish_non_exhaustive() } } diff --git a/packages/perseus/src/plugins/functional.rs b/packages/perseus/src/plugins/functional.rs index 6717e49656..887c53d857 100644 --- a/packages/perseus/src/plugins/functional.rs +++ b/packages/perseus/src/plugins/functional.rs @@ -2,11 +2,10 @@ use super::*; #[cfg(not(target_arch = "wasm32"))] use crate::errors::Error; use crate::errors::PluginError; -use crate::Html; use std::any::Any; use std::collections::HashMap; #[cfg(not(target_arch = "wasm32"))] -use std::rc::Rc; +use std::sync::Arc; /// An action which can be taken by many plugins. When run, a functional action /// will return a map of plugin names to their return types. @@ -18,7 +17,7 @@ impl<A, R> PluginAction<A, R, HashMap<String, R>> for FunctionalPluginAction<A, fn run( &self, action_data: A, - plugin_data: &HashMap<String, Box<dyn Any + Send>>, + plugin_data: &HashMap<String, Box<dyn Any + Send + Sync>>, ) -> Result<HashMap<String, R>, PluginError> { let mut returns = HashMap::new(); for (plugin_name, runner) in &self.runners { @@ -41,7 +40,10 @@ impl<A, R> PluginAction<A, R, HashMap<String, R>> for FunctionalPluginAction<A, fn register_plugin( &mut self, name: &str, - runner: impl Fn(&A, &(dyn Any + Send)) -> Result<R, Box<dyn std::error::Error>> + Send + 'static, + runner: impl Fn(&A, &(dyn Any + Send + Sync)) -> Result<R, Box<dyn std::error::Error + Send + Sync>> + + Send + + Sync + + 'static, ) { self.register_plugin_box(name, Box::new(runner)) } @@ -68,8 +70,8 @@ impl<A, R> std::fmt::Debug for FunctionalPluginAction<A, R> { /// Actions designed to be compatible with other plugins such that two plugins /// can execute the same action. -#[derive(Debug)] -pub struct FunctionalPluginActions<G: Html> { +#[derive(Debug, Default)] +pub struct FunctionalPluginActions { /// The all-powerful action that can modify the Perseus engine itself. /// Because modifying the code you're running doesn't work with compiled /// languages like Rust, this has its own command in the CLI, `perseus @@ -85,7 +87,7 @@ pub struct FunctionalPluginActions<G: Html> { pub tinker: FunctionalPluginAction<(), ()>, /// Actions pertaining to the modification of settings created with /// `PerseusApp`. - pub settings_actions: FunctionalPluginSettingsActions<G>, + pub settings_actions: FunctionalPluginSettingsActions, /// Actions pertaining to the build process. #[cfg(not(target_arch = "wasm32"))] pub build_actions: FunctionalPluginBuildActions, @@ -100,27 +102,11 @@ pub struct FunctionalPluginActions<G: Html> { /// Actions pertaining to the client-side code. pub client_actions: FunctionalPluginClientActions, } -impl<G: Html> Default for FunctionalPluginActions<G> { - fn default() -> Self { - Self { - tinker: FunctionalPluginAction::default(), - settings_actions: FunctionalPluginSettingsActions::<G>::default(), - #[cfg(not(target_arch = "wasm32"))] - build_actions: FunctionalPluginBuildActions::default(), - #[cfg(not(target_arch = "wasm32"))] - export_actions: FunctionalPluginExportActions::default(), - #[cfg(not(target_arch = "wasm32"))] - export_error_page_actions: FunctionalPluginExportErrorPageActions::default(), - server_actions: FunctionalPluginServerActions::default(), - client_actions: FunctionalPluginClientActions::default(), - } - } -} /// Functional actions that pertain to altering the settings exported from /// `PerseusApp`. -#[derive(Debug)] -pub struct FunctionalPluginSettingsActions<G: Html> { +#[derive(Debug, Default)] +pub struct FunctionalPluginSettingsActions { /// Adds additional static aliases. Note that a static alias is a mapping of /// a URL path to a filesystem path (relative to the project root). /// These will be vetted to ensure they don't access anything outside the @@ -128,24 +114,10 @@ pub struct FunctionalPluginSettingsActions<G: Html> { /// not run. Note that these have the power to override the user's static /// aliases. pub add_static_aliases: FunctionalPluginAction<(), HashMap<String, String>>, - /// Adds additional templates. These will be applied to both the templates - /// map and the templates list (separate entities), and they must be - /// generic about Sycamore rendering backends. Note that these have the - /// power to override the user's templates. - pub add_templates: FunctionalPluginAction<(), Vec<crate::Template<G>>>, /// Actions pertaining to the HTML shell, in their own category for /// cleanliness (as there are quite a few). pub html_shell_actions: FunctionalPluginHtmlShellActions, } -impl<G: Html> Default for FunctionalPluginSettingsActions<G> { - fn default() -> Self { - Self { - add_static_aliases: FunctionalPluginAction::default(), - add_templates: FunctionalPluginAction::default(), - html_shell_actions: FunctionalPluginHtmlShellActions::default(), - } - } -} /// Functional actions that pertain to the HTML shell. /// @@ -186,7 +158,7 @@ pub struct FunctionalPluginBuildActions { /// Runs after the build process if it completes successfully. pub after_successful_build: FunctionalPluginAction<(), ()>, /// Runs after the build process if it fails. - pub after_failed_build: FunctionalPluginAction<Rc<Error>, ()>, + pub after_failed_build: FunctionalPluginAction<Arc<Error>, ()>, } /// Functional actions that pertain to the export process. #[cfg(not(target_arch = "wasm32"))] @@ -198,19 +170,19 @@ pub struct FunctionalPluginExportActions { /// successfully. pub after_successful_build: FunctionalPluginAction<(), ()>, /// Runs after the build stage in the export process if it fails. - pub after_failed_build: FunctionalPluginAction<Rc<Error>, ()>, + pub after_failed_build: FunctionalPluginAction<Arc<Error>, ()>, /// Runs after the export process if it fails. - pub after_failed_export: FunctionalPluginAction<Rc<Error>, ()>, + pub after_failed_export: FunctionalPluginAction<Arc<Error>, ()>, /// Runs if copying the static directory failed. - pub after_failed_static_copy: FunctionalPluginAction<Rc<Error>, ()>, + pub after_failed_static_copy: FunctionalPluginAction<Arc<Error>, ()>, /// Runs if copying a static alias that was a directory failed. The argument /// to this is a tuple of the from and to locations of the copy, along with /// the error. - pub after_failed_static_alias_dir_copy: FunctionalPluginAction<Rc<Error>, ()>, + pub after_failed_static_alias_dir_copy: FunctionalPluginAction<Arc<Error>, ()>, /// Runs if copying a static alias that was a file failed. The argument to /// this is a tuple of the from and to locations of the copy, along with the /// error. - pub after_failed_static_alias_file_copy: FunctionalPluginAction<Rc<Error>, ()>, + pub after_failed_static_alias_file_copy: FunctionalPluginAction<Arc<Error>, ()>, /// Runs after the export process if it completes successfully. pub after_successful_export: FunctionalPluginAction<(), ()>, } @@ -225,7 +197,7 @@ pub struct FunctionalPluginExportErrorPageActions { /// Runs after a error page was exported successfully. pub after_successful_export_error_page: FunctionalPluginAction<(), ()>, /// Runs if writing to the output file failed. Error and filename are given. - pub after_failed_write: FunctionalPluginAction<Rc<Error>, ()>, + pub after_failed_write: FunctionalPluginAction<Arc<Error>, ()>, } /// Functional actions that pertain to the server. #[derive(Default, Debug)] @@ -242,4 +214,16 @@ pub struct FunctionalPluginClientActions { /// Runs before anything else in the browser. Note that this runs after /// panics have been set to go to the console. pub start: FunctionalPluginAction<(), ()>, + /// Runs in the event of a full Perseus crash. This is not a panic, but + /// the experience of a critical error that prevents the instantiation of + /// a reactor, router, or the like. This is an *excellent* opportunity for + /// analytics to report that your app has completely failed (although the + /// user will have been neatly prompted). + /// + /// If this panics, there isn't any explicit problem, but it's a bit rude + /// to kick your app when it's down, don't you think? That said, the whole + /// thing's about to blow up anyway. + /// + /// Any error responses here will lead to a panic. + pub crash: FunctionalPluginAction<(), ()>, } diff --git a/packages/perseus/src/plugins/mod.rs b/packages/perseus/src/plugins/mod.rs index 2666de6cdf..a270b109a4 100644 --- a/packages/perseus/src/plugins/mod.rs +++ b/packages/perseus/src/plugins/mod.rs @@ -12,9 +12,7 @@ pub use plugins_list::Plugins; /// A helper function for plugins that don't take any functional actions. This /// just inserts and empty registrar. -pub fn empty_functional_actions_registrar<G: crate::Html>( - _: FunctionalPluginActions<G>, -) -> FunctionalPluginActions<G> { +pub fn empty_functional_actions_registrar(_: FunctionalPluginActions) -> FunctionalPluginActions { FunctionalPluginActions::default() } diff --git a/packages/perseus/src/plugins/plugin.rs b/packages/perseus/src/plugins/plugin.rs index e2465417a7..97f427cef2 100644 --- a/packages/perseus/src/plugins/plugin.rs +++ b/packages/perseus/src/plugins/plugin.rs @@ -1,10 +1,8 @@ use crate::plugins::*; -use crate::Html; use std::any::Any; use std::marker::PhantomData; -type FunctionalActionsRegistrar<G> = - Box<dyn Fn(FunctionalPluginActions<G>) -> FunctionalPluginActions<G>>; +type FunctionalActionsRegistrar = Box<dyn Fn(FunctionalPluginActions) -> FunctionalPluginActions>; type ControlActionsRegistrar = Box<dyn Fn(ControlPluginActions) -> ControlPluginActions>; /// The environments a plugin can run in. These will affect Wasm bundle size. @@ -26,14 +24,14 @@ pub enum PluginEnv { /// A Perseus plugin. This must be exported by all plugin crates so the user can /// register the plugin easily. -pub struct Plugin<G: Html, D: Any + Send> { +pub struct Plugin<D: Any + Send + Sync> { /// The machine name of the plugin, which will be used as a key in a HashMap /// with many other plugins. This should be the public crate name in all /// cases. pub name: String, /// A function that will be provided functional actions. It should then /// register runners from the plugin for every action that it takes. - pub functional_actions_registrar: FunctionalActionsRegistrar<G>, + pub functional_actions_registrar: FunctionalActionsRegistrar, /// A function that will be provided control actions. It should then /// register runners from the plugin for every action that it takes. pub control_actions_registrar: ControlActionsRegistrar, @@ -42,7 +40,7 @@ pub struct Plugin<G: Html, D: Any + Send> { plugin_data_type: PhantomData<D>, } -impl<G: Html, D: Any + Send> std::fmt::Debug for Plugin<G, D> { +impl<D: Any + Send + Sync> std::fmt::Debug for Plugin<D> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Plugin") .field("name", &self.name) @@ -50,12 +48,12 @@ impl<G: Html, D: Any + Send> std::fmt::Debug for Plugin<G, D> { .finish() } } -impl<G: Html, D: Any + Send> Plugin<G, D> { +impl<D: Any + Send + Sync> Plugin<D> { /// Creates a new plugin with a name, functional actions, control actions, /// and whether or not the plugin is tinker-only. pub fn new( name: &str, - functional_actions_registrar: impl Fn(FunctionalPluginActions<G>) -> FunctionalPluginActions<G> + functional_actions_registrar: impl Fn(FunctionalPluginActions) -> FunctionalPluginActions + 'static, control_actions_registrar: impl Fn(ControlPluginActions) -> ControlPluginActions + 'static, env: PluginEnv, diff --git a/packages/perseus/src/plugins/plugins_list.rs b/packages/perseus/src/plugins/plugins_list.rs index 75de63a346..badb54fa21 100644 --- a/packages/perseus/src/plugins/plugins_list.rs +++ b/packages/perseus/src/plugins/plugins_list.rs @@ -1,36 +1,27 @@ use crate::plugins::*; -use crate::Html; use std::any::Any; use std::collections::HashMap; -type PluginDataMap = HashMap<String, Box<dyn Any + Send>>; +type PluginDataMap = HashMap<String, Box<dyn Any + Send + Sync>>; /// A representation of all the plugins used by an app. /// /// Due to the sheer number and complexity of nested fields, this is best /// transferred in an `Rc`, which unfortunately results in double indirection /// for runner functions. -pub struct Plugins<G: Html> { +#[derive(Default)] +pub struct Plugins { /// The functional actions that this plugin takes. This is defined by /// default such that all actions are assigned to a default, and so they /// can all be run without long chains of matching `Option<T>`s. - pub functional_actions: FunctionalPluginActions<G>, + pub functional_actions: FunctionalPluginActions, /// The control actions that this plugin takes. This is defined by default /// such that all actions are assigned to a default, and so they can all /// be run without long chains of matching `Option<T>`s. pub control_actions: ControlPluginActions, plugin_data: PluginDataMap, } -impl<G: Html> Default for Plugins<G> { - fn default() -> Self { - Self { - functional_actions: FunctionalPluginActions::<G>::default(), - control_actions: ControlPluginActions::default(), - plugin_data: HashMap::default(), - } - } -} -impl<G: Html> std::fmt::Debug for Plugins<G> { +impl std::fmt::Debug for Plugins { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Plugins") .field("functional_actions", &self.functional_actions) @@ -38,7 +29,7 @@ impl<G: Html> std::fmt::Debug for Plugins<G> { .finish() } } -impl<G: Html> Plugins<G> { +impl Plugins { /// Creates a new instance of `Plugins`, with no actions taken by any /// plugins, and the data map empty. pub fn new() -> Self { @@ -51,12 +42,13 @@ impl<G: Html> Plugins<G> { /// server-side (including tinker-time and the build process). // We allow unused variables and the like for linting because otherwise any // errors in Wasm compilation will show these up, which is annoying - pub fn plugin<D: Any + Send>( + pub fn plugin<D: Any + Send + Sync>( #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] mut self, // This is a function so that it never gets called if we're compiling for Wasm, which means // Rust eliminates it as dead code! - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] plugin: impl Fn() -> Plugin<G, D> - + Send, + #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] plugin: impl Fn() -> Plugin<D> + + Send + + Sync, #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] plugin_data: D, ) -> Self { // If we're compiling for Wasm, plugins that don't run on the client side @@ -71,7 +63,7 @@ impl<G: Html> Plugins<G> { panic!("attempted to register plugin that can run on the client with `.plugin()`, this plugin should be registered with `.plugin_with_client_privilege()` (this will increase your final bundle size)") } // Insert the plugin data - let plugin_data: Box<dyn Any + Send> = Box::new(plugin_data); + let plugin_data: Box<dyn Any + Send + Sync> = Box::new(plugin_data); let res = self.plugin_data.insert(plugin.name.clone(), plugin_data); // If there was an old value, there are two plugins with the same name, which is // very bad (arbitrarily inconsistent behavior overriding) @@ -91,10 +83,10 @@ impl<G: Html> Plugins<G> { /// compilation feasible and to emphasize to users what's increasing their /// bundle sizes. Note that this should also be used for plugins that /// run on both the client and server. - pub fn plugin_with_client_privilege<D: Any + Send>( + pub fn plugin_with_client_privilege<D: Any + Send + Sync>( mut self, // This is a function to preserve a similar API interface with `.plugin()` - plugin: impl Fn() -> Plugin<G, D> + Send, + plugin: impl Fn() -> Plugin<D> + Send + Sync, plugin_data: D, ) -> Self { let plugin = plugin(); @@ -104,7 +96,7 @@ impl<G: Html> Plugins<G> { panic!("attempted to register plugin that doesn't ever run on the client with `.plugin_with_client_privilege()`, you should use `.plugin()` instead") } // Insert the plugin data - let plugin_data: Box<dyn Any + Send> = Box::new(plugin_data); + let plugin_data: Box<dyn Any + Send + Sync> = Box::new(plugin_data); let res = self.plugin_data.insert(plugin.name.clone(), plugin_data); // If there was an old value, there are two plugins with the same name, which is // very bad (arbitrarily inconsistent behavior overriding) diff --git a/packages/perseus/src/reactor/error.rs b/packages/perseus/src/reactor/error.rs new file mode 100644 index 0000000000..6894866890 --- /dev/null +++ b/packages/perseus/src/reactor/error.rs @@ -0,0 +1,159 @@ +use super::Reactor; +use crate::{ + error_views::{ErrorContext, ErrorPosition, ErrorViews}, + errors::ClientError, + template::BrowserNodeType, + utils::{render_or_hydrate, replace_head}, +}; +#[cfg(not(target_arch = "wasm32"))] +use std::rc::Rc; +use std::{panic::PanicInfo, sync::Arc}; +use sycamore::{ + prelude::{create_scope_immediate, try_use_context, view, Scope, ScopeDisposer}, + view::View, + web::SsrNode, +}; + +impl Reactor<BrowserNodeType> { + /// This reports an error to the failsafe mechanism, which will handle it + /// appropriately. This will determine the capabilities the error view + /// will have access to from the scope provided. + /// + /// This returns the disposer for the underlying error scope, which must be + /// handled appropriately, or a memory leak will occur. Leaking an error + /// scope is never permissible. A boolean of whether or not the error took + /// up the whole page or not is also returned, which can be used to guide + /// what should be done with the disposer. + /// + /// Obviously, since this is a method on a reactor, this does not handle + /// critical errors caused by not being able to create a reactor. + /// + /// This **does not** handle widget errors (unless they're popups). + #[must_use] + pub(crate) fn report_err<'a>( + &self, + cx: Scope<'a>, + err: ClientError, + ) -> (ScopeDisposer<'a>, bool) { + // Determine where this should be placed + let pos = match self.is_first.get() { + // On an initial load, we'll use a popup, unless it's a server-given error + true => match err { + ClientError::ServerError { .. } => ErrorPosition::Page, + _ => ErrorPosition::Popup, + }, + // On a subsequent load, this is the responsibility of the user + false => match self.error_views.subsequent_err_should_be_popup(&err) { + true => ErrorPosition::Popup, + false => ErrorPosition::Page, + }, + }; + + let (head_str, body_view, disposer) = self.error_views.handle(cx, err, pos); + + match pos { + // For page-wide errors, we need to set the head + ErrorPosition::Page => { + replace_head(&head_str); + self.current_view.set(body_view); + (disposer, true) + } + ErrorPosition::Popup => { + self.popup_error_view.set(body_view); + (disposer, false) + } + // We don't handle widget errors in this function + ErrorPosition::Widget => unreachable!(), + } + } + + /// Creates the infrastructure necessary to handle a critical error, and + /// then displays it. This is intended for use if the reactor cannot be + /// instantiated, and it takes the app-level context to verify this. + /// + /// # Panics + /// This will panic if given a scope in which a reactor exists. + /// + /// # Visibility + /// This is broadly part of Perseus implementation details, and is exposed + /// only for those foregoing `#[perseus::main]` or + /// `#[perseus::browser_main]` to build their own custom browser-side + /// entrypoint (do not do this unless you really need to). + pub fn handle_critical_error( + cx: Scope, + err: ClientError, + error_views: &ErrorViews<BrowserNodeType>, + ) { + // We do NOT want this called if there is a reactor (but, if it is, we have no + // clue about the calling situation, so it's safest to just panic) + assert!(try_use_context::<Reactor<BrowserNodeType>>(cx).is_none(), "attempted to handle 'critical' error, but a reactor was found (this is a programming error)"); + + let popup_error_root = Self::get_popup_err_elem(); + // This will determine the `Static` error context (we guaranteed there's no + // reactor above). We don't care about the head in a popup. + let (_, err_view, disposer) = error_views.handle(cx, err, ErrorPosition::Popup); + render_or_hydrate( + cx, + view! { cx, + // This is not reactive, as there's no point in making it so + (err_view) + }, + popup_error_root, + true, // Browser-side-only error, so force a full render + ); + // SAFETY: We're outside the child scope + unsafe { + disposer.dispose(); + } + } + /// Creates the infrastructure necessary to handle a panic, and then + /// displays an error created by the user's [`ErrorViews`]. This + /// function will only panic if certain fundamental functions of the web + /// APIs are not defined, in which case no error message could ever be + /// displayed to the user anyway. + /// + /// A handler is manually provided to this, because the [`ErrorViews`] + /// are typically not thread-safe once extracted from `PerseusApp`. + /// + /// # Visibility + /// Under absolutely no circumstances should this function **ever** be + /// called outside a Perseus panic handler set in the entrypoint! It is + /// exposed for custom entrypoints only. + pub fn handle_panic( + panic_info: &PanicInfo, + handler: Arc< + dyn Fn( + Scope, + ClientError, + ErrorContext, + ErrorPosition, + ) -> (View<SsrNode>, View<BrowserNodeType>) + + Send + + Sync, + >, + ) { + let popup_error_root = Self::get_popup_err_elem(); + + // The standard library handles all the hard parts here + let msg = panic_info.to_string(); + // The whole app is about to implode, we are not keeping this scope + // around + create_scope_immediate(|cx| { + let (_head, body) = handler( + cx, + ClientError::Panic(msg), + ErrorContext::Static, + ErrorPosition::Popup, + ); + render_or_hydrate( + cx, + view! { cx, + // This is not reactive, as there's no point in making it so + (body) + }, + popup_error_root, + true, // Browser-side-only error, so force a full render + ); + }); + } +} diff --git a/packages/perseus/src/reactor/global_state.rs b/packages/perseus/src/reactor/global_state.rs new file mode 100644 index 0000000000..69e2750677 --- /dev/null +++ b/packages/perseus/src/reactor/global_state.rs @@ -0,0 +1,228 @@ +use super::Reactor; +#[cfg(target_arch = "wasm32")] +use crate::state::FrozenGlobalState; +use crate::{ + errors::*, + state::{AnyFreeze, GlobalStateType, MakeRx, MakeUnrx}, +}; +use serde::{de::DeserializeOwned, Serialize}; +use sycamore::{ + prelude::{create_ref, Scope}, + web::Html, +}; + +// These methods are used for acquiring the global state on both the +// browser-side and the engine-side +impl<G: Html> Reactor<G> { + /// Gets the global state. Note that this can only be used for reactive + /// global state, since Perseus always expects your global state to be + /// reactive. + /// + /// # Panics + /// This will panic if the app has no global state. If you don't know + /// whether or not there is global state, use `.try_global_state()` + /// instead. + // This function takes the final ref struct as a type parameter! That + // complicates everything substantially. + pub fn get_global_state<'a, I>(&self, cx: Scope<'a>) -> &'a I + where + I: MakeUnrx + AnyFreeze + Clone, + I::Unrx: MakeRx<Rx = I>, + { + // Warn the user about the perils of having no build-time global state handler + self.try_get_global_state::<I>(cx).unwrap().expect("you requested global state, but none exists for this app (if you're generating it at request-time, then you can't access it at build-time; try adding a build-time generator too, or target-gating your use of global state for the browser-side only)") + } + /// The underlying logic for `.get_global_state()`, except this will return + /// `None` if the app does not have global state. + /// + /// This will return an error if the state from the server was found to be + /// invalid. + pub fn try_get_global_state<'a, I>(&self, cx: Scope<'a>) -> Result<Option<&'a I>, ClientError> + where + I: MakeUnrx + AnyFreeze + Clone, + I::Unrx: MakeRx<Rx = I>, + { + let global_state_ty = self.global_state.0.borrow(); + // Bail early if the app doesn't support global state + if let GlobalStateType::None = *global_state_ty { + return Ok(None); + } + // Getting the held state may change this, so we have to drop it + drop(global_state_ty); + + let intermediate_state = + if let Some(held_state) = self.get_held_global_state::<I::Unrx>()? { + held_state + } else { + let global_state_ty = self.global_state.0.borrow(); + // We'll get the server-given global state + if let GlobalStateType::Server(server_state) = &*global_state_ty { + // Fall back to the state we were given, first + // giving it a type (this just sets a phantom type parameter) + let typed_state = server_state.clone().change_type::<I::Unrx>(); + // This attempts a deserialization from a `Value`, which could fail + let unrx = typed_state + .into_concrete() + .map_err(|err| ClientInvariantError::InvalidState { source: err })?; + let rx = unrx.make_rx(); + // Set that as the new active global state + drop(global_state_ty); + let mut active_global_state = self.global_state.0.borrow_mut(); + *active_global_state = GlobalStateType::Loaded(Box::new(rx.clone())); + + rx + } else { + // There are two alternatives: `None` (handled with an early bail above) and + // `Loaded`, the latter of which would have been handled as the + // active state above (even if we prioritized frozen state, that + // would have returned something; if there was an active global state, + // we would've dealt with it). If we're here it was `Server`. + unreachable!() + } + }; + + Ok(Some(create_ref(cx, intermediate_state))) + } + + /// Determines if the global state should use the state given by the server, + /// or whether it has other state in the frozen/active state systems. If the + /// latter is true, this will instantiate them appropriately and return + /// them. If this returns `None`, the server-provided state should be + /// used. + /// + /// To understand the exact logic chain this uses, please refer to the + /// flowchart of the Perseus reactive state platform in the book. + /// + /// Note: on the engine-side, there is no such thing as frozen state, and + /// the active state will always be empty, so this will simply return + /// `None`. + #[cfg(target_arch = "wasm32")] + fn get_held_global_state<S>(&self) -> Result<Option<S::Rx>, ClientError> + where + S: MakeRx + Serialize + DeserializeOwned, + S::Rx: MakeUnrx<Unrx = S> + AnyFreeze + Clone, + { + // See if we can get both the active and frozen states + let frozen_app_full = self.frozen_app.borrow(); + if let Some((_, thaw_prefs, _)) = &*frozen_app_full { + // Check against the thaw preferences if we should prefer frozen state over + // active state + if thaw_prefs.global_prefer_frozen { + drop(frozen_app_full); + // We'll fall back to active state if no frozen state is available + match self.get_frozen_global_state_and_register::<S>()? { + Some(state) => Ok(Some(state)), + None => self.get_active_global_state::<S>(), + } + } else { + drop(frozen_app_full); + // We're preferring active state, but we'll fall back to frozen state if none is + // available + match self.get_active_global_state::<S>()? { + Some(state) => Ok(Some(state)), + None => self.get_frozen_global_state_and_register::<S>(), + } + } + } else { + // No frozen app exists, so we of course shouldn't prioritize it + self.get_active_global_state::<S>() + } + } + #[cfg(not(target_arch = "wasm32"))] + fn get_held_global_state<S>(&self) -> Result<Option<S::Rx>, ClientError> + where + S: MakeRx + Serialize + DeserializeOwned, + S::Rx: MakeUnrx<Unrx = S> + AnyFreeze + Clone, + { + Ok(None) + } + + /// Attempts to the get the active global state. Of course, this does not + /// register anything in the state store. This may return an error on a + /// downcast failure (which is probably the user's fault for providing + /// the wrong type argument, but it's still an invariant failure). + #[cfg(target_arch = "wasm32")] + fn get_active_global_state<S>(&self) -> Result<Option<S::Rx>, ClientError> + where + S: MakeRx + Serialize + DeserializeOwned, + S::Rx: MakeUnrx<Unrx = S> + AnyFreeze + Clone, + { + // This just attempts a downcast to `S::Rx` + self.global_state.0.borrow().parse_active::<S>() + } + /// Attempts to extract the frozen global state from any currently + /// registered frozen app, registering what it finds. This assumes that + /// the thaw preferences have already been accounted for. + /// + /// This assumes that the app actually supports global state. + #[cfg(target_arch = "wasm32")] + fn get_frozen_global_state_and_register<S>(&self) -> Result<Option<S::Rx>, ClientError> + where + S: MakeRx + Serialize + DeserializeOwned, + S::Rx: MakeUnrx<Unrx = S> + AnyFreeze + Clone, + { + let frozen_app_full = self.frozen_app.borrow(); + if let Some((frozen_app, _, is_hsr)) = &*frozen_app_full { + #[cfg(not(all(debug_assertions, feature = "hsr")))] + assert!( + !is_hsr, + "attempted to invoke hsr-style thaw in non-hsr environment" + ); + + match &frozen_app.global_state { + FrozenGlobalState::Some(state_str) => { + // Deserialize into the unreactive version + let unrx = match serde_json::from_str::<S>(&state_str) { + Ok(unrx) => unrx, + // A corrupted frozen state should explicitly bubble up to be an error, + // *unless* this is HSR, in which case the data model has just been changed, + // and we should move on + Err(_) if *is_hsr => return Ok(None), + Err(err) => { + return Err( + ClientThawError::InvalidFrozenGlobalState { source: err }.into() + ) + } + }; + // This returns the reactive version of the unreactive version of `R`, which + // is why we have to make everything else do the same + // Then we convince the compiler that that actually is `R` with the + // ludicrous trait bound at the beginning of this function + let rx = unrx.make_rx(); + // And we'll register this as the new active global state + let mut active_global_state = self.global_state.0.borrow_mut(); + *active_global_state = GlobalStateType::Loaded(Box::new(rx.clone())); + // Now we should remove this from the frozen state so we don't fall back to + // it again + drop(frozen_app_full); + let mut frozen_app_val = self.frozen_app.take().unwrap(); // We're literally in a conditional that checked this + frozen_app_val.0.global_state = FrozenGlobalState::Used; + let mut frozen_app = self.frozen_app.borrow_mut(); + *frozen_app = Some(frozen_app_val); + + Ok(Some(rx)) + } + // The state hadn't been modified from what the server provided, so + // we'll just use that (note: this really means it hadn't been instantiated + // yet). + // We'll handle global state that has already been used in the same way (this + // is needed because, unlike a page/widget state map, we can't just remove + // the global state from the frozen app, so this acts as a placeholder). + FrozenGlobalState::Server | FrozenGlobalState::Used => Ok(None), + // There was no global state last time, but if we're here, we've + // checked that the app is using global state. If we're using HSR, + // allow the data model change, otherwise ths frozen state will be considered + // invalid. + FrozenGlobalState::None => { + if *is_hsr { + Ok(None) + } else { + Err(ClientThawError::NoFrozenGlobalState.into()) + } + } + } + } else { + Ok(None) + } + } +} diff --git a/packages/perseus/src/reactor/hsr.rs b/packages/perseus/src/reactor/hsr.rs new file mode 100644 index 0000000000..7497791af8 --- /dev/null +++ b/packages/perseus/src/reactor/hsr.rs @@ -0,0 +1,67 @@ +use super::Reactor; +use crate::state::IdbFrozenStateStore; +use sycamore::web::Html; +use wasm_bindgen::JsValue; + +impl<G: Html> Reactor<G> { + /// Freezes the app's state to IndexedDB to be accessed in future. This + /// takes a pre-determined frozen state to avoid *really* annoying + /// lifetime errors. + pub(crate) async fn hsr_freeze(frozen_state: String) { + // We use a custom name so we don't interfere with any state freezing the user's + // doing independently + let idb_store = match IdbFrozenStateStore::new_with_name("perseus_hsr").await { + Ok(idb_store) => idb_store, + Err(err) => return log(&format!("IndexedDB setup error: {}.", err)), + }; + match idb_store.set(&frozen_state).await { + Ok(_) => log("State frozen."), + Err(err) => log(&format!("State freezing error: {}.", err)), + }; + } + + /// Thaws a previous state frozen in development. + pub(crate) async fn hsr_thaw(&self) { + use crate::state::{PageThawPrefs, ThawPrefs}; + + let idb_store = match IdbFrozenStateStore::new_with_name("perseus_hsr").await { + Ok(idb_store) => idb_store, + Err(err) => return log(&format!("IndexedDB setup error: {}.", err)), + }; + let frozen_state = match idb_store.get().await { + Ok(Some(frozen_state)) => frozen_state, + // If there's no frozen state available, we'll proceed as usual + Ok(None) => return, + Err(err) => return log(&format!("Frozen state acquisition error: {}.", err)), + }; + + // This is designed to override everything to restore the app to its previous + // state, so we should override everything This isn't problematic because + // the state will be frozen right before the reload and restored right after, so + // we literally can't miss anything (unless there's auto-typing tech involved!) + let thaw_prefs = ThawPrefs { + page: PageThawPrefs::IncludeAll, + global_prefer_frozen: true, + }; + // This invokes a modified type of thawing that is internal-only, which is more + // lenient with invalid and/or corrupted state, thus allowing the user to + // radically change their data model. In such cases, the frozen HSR + // state will simply be bypassed. + match self._thaw(&frozen_state, thaw_prefs, true) { + Ok(_) => log("State restored."), + Err(_) => log("Stored state corrupted, waiting for next code change to override."), + }; + + // We don't want this old state to persist if the user manually reloads (they'd + // be greeted with state that's probably out-of-date) + match idb_store.clear().await { + Ok(_) => (), + Err(err) => log(&format!("Stale state clearing error: {}.", err)), + } + } +} + +/// An internal function for logging data about HSR. +fn log(msg: &str) { + web_sys::console::log_1(&JsValue::from("[HSR]: ".to_string() + msg)); +} diff --git a/packages/perseus/src/reactor/initial_load.rs b/packages/perseus/src/reactor/initial_load.rs new file mode 100644 index 0000000000..f7fa535e58 --- /dev/null +++ b/packages/perseus/src/reactor/initial_load.rs @@ -0,0 +1,301 @@ +use std::collections::HashMap; + +use crate::{ + checkpoint, + error_views::ServerErrorData, + errors::*, + i18n::detect_locale, + path::PathMaybeWithLocale, + router::{match_route, FullRouteInfo, FullRouteVerdict, RouterLoadState}, + state::TemplateState, + utils::get_path_prefix_client, +}; +use serde_json::Value; +use sycamore::{ + prelude::{Scope, ScopeDisposer}, + view::View, + web::Html, +}; +use web_sys::Element; + +use super::{Reactor, WindowVariable}; + +impl<G: Html> Reactor<G> { + /// Gets the initial view to hydrate, which will be the same as what the + /// engine-side rendered and provided. This will automatically extract + /// the current path from the browser. + pub(crate) fn get_initial_view<'a>( + &self, + cx: Scope<'a>, + ) -> Result<InitialView<'a, G>, ClientError> { + // Get the current path, removing any base paths to avoid relative path locale + // redirection loops (in previous versions of Perseus, we used Sycamore to + // get the path, and it strips this out automatically) + // Note that this does work with full URL paths, because + // `get_path_prefix_client` does automatically get just the pathname + // component. + let path_prefix = get_path_prefix_client(); + let path = web_sys::window().unwrap().location().pathname().unwrap(); + let path = if path.starts_with(&path_prefix) { + path.strip_prefix(&path_prefix).unwrap() + } else { + &path + }; + let path = js_sys::decode_uri_component(&path) + .map_err(|_| ClientPlatformError::InitialPath)? + .as_string() + .ok_or(ClientPlatformError::InitialPath)?; + + // Start by figuring out what template we should be rendering + let path_segments = path + .split('/') + .filter(|s| !s.is_empty()) + .collect::<Vec<&str>>(); // This parsing is identical to the Sycamore router's + let verdict = match_route( + &path_segments, + &self.render_cfg, + &self.entities, + &self.locales, + ); + // We'll need this later for setting the router state + let slim_verdict = verdict.clone(); + match &verdict.into_full(&self.entities) { + // WARNING: This will be triggered on *all* incremental paths, even if + // the serber returns a 404! + FullRouteVerdict::Found(FullRouteInfo { + path, + entity, + locale, + // Since we're not requesting anything from the server, we don't need to worry about + // whether it's an incremental match or not + was_incremental_match: _, + }) => { + let full_path = PathMaybeWithLocale::new(&path, &locale); + // Update the router state as we try to load (since this is the initial + // view, this will be the first change since the server) + self.router_state.set_load_state(RouterLoadState::Loading { + template_name: entity.get_path(), + path: full_path.clone(), + }); + self.router_state.set_last_verdict(slim_verdict); + + // Get the initial state and decide what to do from that. We can guarantee that + // this locale is supported because it came from `match_route`. + let state = self.get_initial_state(locale)?; + + // Get the translator from the page (this has to exist, or the server stuffed + // up); doing this without a network request minimizes + // the time to interactivity (improving UX drastically), while meaning that we + // never have to fetch translations separately unless the user switches locales + let translations_str = match WindowVariable::new_str("__PERSEUS_TRANSLATIONS") { + WindowVariable::Some(state_str) => state_str, + WindowVariable::Malformed | WindowVariable::None => { + return Err(ClientInvariantError::Translations.into()) + } + }; + // This will cache the translator internally in the reactor (which can be + // accessed later through the`t!` macro etc.). This locale is guaranteed to + // be supported, because it came from a `match_route`. + self.translations_manager + .set_translator_for_translations_str(&locale, &translations_str)?; + + #[cfg(feature = "cache-initial-load")] + { + // Cache the page's head in the PSS (getting it as reliably as we can, which + // isn't perfect, hence the feature-gate). Without this, we + // would have to get the head from the server on + // a subsequent load back to this page, which isn't ideal. + let head_str = Self::get_head()?; + self.state_store.add_head(&full_path, head_str, false); // We know this is a page + } + + // Get the widget states and register them all as preloads in the state store so + // they can be accessed by the `Widget` component. Like other + // window variables, this will always be present, even if there + // were no widgets used. + let widget_states = + match WindowVariable::<HashMap<PathMaybeWithLocale, Value>>::new_obj( + "__PERSEUS_INITIAL_WIDGET_STATES", + ) { + WindowVariable::Some(states) => states, + WindowVariable::None | WindowVariable::Malformed => { + return Err(ClientInvariantError::WidgetStates.into()) + } + }; + for (widget_path, state_res) in widget_states.into_iter() { + // NOTE: `state_res` could be `ServerErrorData`! + self.state_store.add_initial_widget(widget_path, state_res); + } + + // Render the actual template to the root (done imperatively due to child + // scopes) + let (view, disposer) = entity.render_for_template_client(full_path, state, cx)?; + + Ok(InitialView::View(view, disposer)) + } + // If the user is using i18n, then they'll want to detect the locale on any paths + // missing a locale. Those all go to the same system that redirects to the + // appropriate locale. This returns a full URL to imperatively redirect to. + FullRouteVerdict::LocaleDetection(path) => Ok(InitialView::Redirect(detect_locale( + path.clone(), + &self.locales, + ))), + // Since all unlocalized 404s go to a redirect, we always have a locale here. Provided + // the server is being remotely reasonable, we should have translations too, + // *unless* the error page was exported, in which case we're up the creek. + // TODO Fetch translations with exported error pages? Solution?? + FullRouteVerdict::NotFound { locale } => { + // Check what we have in the error page data. We would expect this to be a + // `ClientError::ServerError { status: 404, source: "page not found" }`, but + // other invariants could have been broken. So, we propagate any errors up + // happily. If this is `Ok(_)`, we have a *serious* problem, as + // that means the engine thought this page was valid, but we + // disagree. This should not happen without tampering, + // so we'll return an invariant error. + // We can guarantee that the locale is supported because it came from a + // `match_route`, even though the route wasn't found. If the app + // isn't using i18n, it will be `xx-XX`. + match self.get_initial_state(locale) { + Err(err) => Err(err), + Ok(_) => Err(ClientInvariantError::RouterMismatch.into()), + } + } + } + } + + /// Gets the initial state injected by the server, if there was any. This is + /// used to differentiate initial loads from subsequent ones, which have + /// different log chains to prevent double-trips (a common SPA problem). + /// + /// # Panics + /// This will panic if the given locale is not supported. + fn get_initial_state(&self, locale: &str) -> Result<TemplateState, ClientError> { + let state_str = match WindowVariable::new_str("__PERSEUS_INITIAL_STATE") { + WindowVariable::Some(state_str) => state_str, + WindowVariable::Malformed | WindowVariable::None => { + return Err(ClientInvariantError::InitialState.into()) + } + }; + + // If there was an error, it's specially injected with this prefix before error + // page data + if state_str.starts_with("error-") { + // We strip the prefix and escape any tab/newline control characters (inserted + // by `fmterr`). Any others are user-inserted, and this is documented. + let err_page_data_str = state_str + .strip_prefix("error-") + .unwrap() + .replace('\n', "\\n") + .replace('\t', "\\t"); + // There will be error page data encoded after `error-` + let err_page_data = match serde_json::from_str::<ServerErrorData>(&err_page_data_str) { + Ok(data) => data, + Err(err) => { + return Err(ClientInvariantError::InitialStateError { source: err }.into()) + } + }; + // This will be sent back to the handler for proper rendering, the only + // difference is that the user won't get a flash to an error page, + // they will have started with an error + let err = ClientError::ServerError { + status: err_page_data.status, + message: err_page_data.msg, + }; + // We do this in here so that even incremental pages that appear fine to the + // router, but that actually error out, trigger this checkpoint + if err_page_data.status == 404 { + checkpoint("not_found"); + } + // In some nice cases, the server will have been able to figure out the locale, + // which we should have (this is one of those things that most sites + // don't bother with because it's not easy to build, and *this* is + // where a framework really shines). If we do have it, it'll be + // in the `__PERSEUS_TRANSLATIONS` variable. If that's there, then the error + // provided will be localized, so, if we can't get the translator, + // we'll prefer to return an internal error that comes up as a popup + // (since we don't want to replace a localized error with an unlocalized one). + // If we know we have something unlocalized, just replace it with whatever we + // have now. + // + // Note: in the case of a server-given error, we'll only not have translations + // if there was an internal error (since `/this-page-does-not-exist` + // would be a locale redirection). + match WindowVariable::new_str("__PERSEUS_TRANSLATIONS") { + // We have translations! Any errors in resolving them fully will be propagated. + // We guarantee that this locale is supported based on the invariants of this + // function. + WindowVariable::Some(translations_str) => self + .translations_manager + .set_translator_for_translations_str(locale, &translations_str)?, + // This would be extremely odd...but it's still a problem that could happen (and + // there *should* be a localized error that the user can see) + WindowVariable::Malformed => return Err(ClientInvariantError::Translations.into()), + // There was probably an internal server error + WindowVariable::None => (), + }; + + Err(err) + } else { + match TemplateState::from_str(&state_str) { + Ok(state) => Ok(state), + // An actual error means the state was provided, but it was malformed, so we'll + // render an error page + Err(_) => Err(ClientInvariantError::InitialState.into()), + } + } + } + + /// Gets the entire contents of the current `<head>`, up to the Perseus + /// head-end comment (which prevents any JS that was loaded later from + /// being included). This is not guaranteed to always get exactly the + /// original head, but it's pretty good, and prevents unnecessary + /// network requests, while enabling the caching of initially loaded + /// pages. + #[cfg(feature = "cache-initial-load")] + fn get_head() -> Result<String, ClientError> { + use wasm_bindgen::JsCast; + + let document = web_sys::window().unwrap().document().unwrap(); + // Get the current head + // The server sends through a head, so we can guarantee that one is present (and + // it's mandated for custom initial views) + let head_node = document.query_selector("head").unwrap().unwrap(); + // Get all the elements after the head boundary (otherwise we'd be duplicating + // the initial stuff) + let els = head_node + .query_selector_all(r#"meta[itemprop='__perseus_head_boundary'] ~ *"#) + .unwrap(); + // No, `NodeList` does not have an iterator implementation... + let mut head_vec = Vec::new(); + for i in 0..els.length() { + let elem: Element = els.get(i).unwrap().unchecked_into(); + // Check if this is the delimiter that denotes the end of the head (it's + // impossible for the user to add anything below here), since we don't + // want to get anything that other scripts might have added (but if that shows + // up, it shouldn't be catastrophic) + if elem.tag_name().to_lowercase() == "meta" + && elem.get_attribute("itemprop") == Some("__perseus_head_end".to_string()) + { + break; + } + let html = elem.outer_html(); + head_vec.push(html); + } + + Ok(head_vec.join("\n")) + } +} + +/// A representation of the possible outcomes of getting the view for the +/// initial load. +pub(crate) enum InitialView<'app, G: Html> { + /// The provided view and scope disposer are ready to render the page. + View(View<G>, ScopeDisposer<'app>), + /// We need to redirect somewhere else, and the *full URL* to redirect to is + /// attached. + /// + /// Currently, this is only used by locale redirection, though this could + /// theoretically also be used for server-level reloads, if those + /// directives are ever supported. + Redirect(String), +} diff --git a/packages/perseus/src/reactor/mod.rs b/packages/perseus/src/reactor/mod.rs new file mode 100644 index 0000000000..8519d71344 --- /dev/null +++ b/packages/perseus/src/reactor/mod.rs @@ -0,0 +1,352 @@ +#[cfg(target_arch = "wasm32")] +mod error; +mod global_state; +#[cfg(all(feature = "hsr", debug_assertions, target_arch = "wasm32"))] +mod hsr; +#[cfg(target_arch = "wasm32")] +mod initial_load; +#[cfg(not(target_arch = "wasm32"))] +mod render_mode; +#[cfg(target_arch = "wasm32")] +mod start; +mod state; +#[cfg(target_arch = "wasm32")] +mod subsequent_load; +mod widget_state; + +#[cfg(target_arch = "wasm32")] +pub(crate) use initial_load::InitialView; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use render_mode::{RenderMode, RenderStatus}; + +// --- Common imports --- +#[cfg(target_arch = "wasm32")] +use crate::template::{BrowserNodeType, EntityMap}; +use crate::{ + i18n::Translator, + state::{GlobalState, GlobalStateType, PageStateStore, TemplateState}, +}; +use sycamore::{ + prelude::{provide_context, use_context, Scope}, + web::Html, +}; + +// --- Engine-side imports --- + +// --- Browser-side imports --- +#[cfg(target_arch = "wasm32")] +use crate::{ + error_views::ErrorViews, + errors::ClientError, + errors::ClientInvariantError, + i18n::{ClientTranslationsManager, Locales, TranslationsManager}, + init::PerseusAppBase, + plugins::PluginAction, + router::RouterState, + state::{FrozenApp, ThawPrefs}, + stores::MutableStore, +}; +#[cfg(target_arch = "wasm32")] +use serde::{de::DeserializeOwned, Serialize}; +#[cfg(target_arch = "wasm32")] +use serde_json::Value; +#[cfg(target_arch = "wasm32")] +use std::{ + cell::{Cell, RefCell}, + collections::HashMap, + rc::Rc, +}; +#[cfg(target_arch = "wasm32")] +use sycamore::{ + reactive::{create_rc_signal, RcSignal}, + view::View, +}; + +/// The core of Perseus' browser-side systems. This forms a central point for +/// all the Perseus state and rendering logic to operate from. In your own code, +/// this will always be available in the Sycamore context system. +/// +/// Note that this is also used on the engine-side for rendering. +#[derive(Debug)] +pub struct Reactor<G: Html> { + /// The state store, which is used to hold all reactive states, along with + /// preloads. + pub(crate) state_store: PageStateStore, + /// The router state. + #[cfg(target_arch = "wasm32")] + pub router_state: RouterState, + /// The user-provided global state, stored with similar mechanics to the + /// state store, although optimised. + global_state: GlobalState, + + // --- Browser-side only --- + /// A previous state the app was once in, still serialized. This will be + /// rehydrated gradually by the template closures. + /// + /// The `bool` in here will be set to `true` if this was created through + /// HSR, which has slightly more lenient thawing procedures to allow for + /// data model changes. + #[cfg(target_arch = "wasm32")] + frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs, bool)>>>, + /// Whether or not this page is the very first to have been rendered since + /// the browser loaded the app. This will be reset on full reloads, and is + /// used internally to determine whether or not we should look for + /// stored HSR state. + #[cfg(target_arch = "wasm32")] + pub(crate) is_first: Cell<bool>, + /// The app's *full* render configuration. Note that a subset of this + /// is contained in the [`RenderMode`] on the engine-side for widget + /// rendering. + #[cfg(target_arch = "wasm32")] + pub(crate) render_cfg: HashMap<String, String>, + /// The app's templates and capsules for use in routing. + #[cfg(target_arch = "wasm32")] + pub(crate) entities: EntityMap<G>, + /// The app's locales. + #[cfg(target_arch = "wasm32")] + pub(crate) locales: Locales, + /// The browser-side translations manager. + #[cfg(target_arch = "wasm32")] + translations_manager: ClientTranslationsManager, + /// The app's error views. + #[cfg(target_arch = "wasm32")] + pub(crate) error_views: Rc<ErrorViews<G>>, + /// A reactive container for the current page-wide view. This will usually + /// contain the contents of the current page, but it may also contain a + /// page-wide error. This will be wrapped in a router. + #[cfg(target_arch = "wasm32")] + current_view: RcSignal<View<BrowserNodeType>>, + /// A reactive container for any popup errors. + #[cfg(target_arch = "wasm32")] + popup_error_view: RcSignal<View<BrowserNodeType>>, + /// The app's root div ID. + #[cfg(target_arch = "wasm32")] + root: String, + + // --- Engine-side only --- + #[cfg(not(target_arch = "wasm32"))] + pub(crate) render_mode: RenderMode<G>, + /// The currently active translator. On the browser-side, this is handled by + /// the more fully-fledged [`ClientTranslationsManager`]. + /// + /// This is provided to the engine-side reactor on instantiation. This can + /// be `None` in certain error view renders. + #[cfg(not(target_arch = "wasm32"))] + translator: Option<Translator>, +} + +// This uses window variables set by the HTML shell, so it should never be used +// on the engine-side +#[cfg(target_arch = "wasm32")] +impl<G: Html, M: MutableStore, T: TranslationsManager> TryFrom<PerseusAppBase<G, M, T>> + for Reactor<G> +{ + type Error = ClientError; + + fn try_from(app: PerseusAppBase<G, M, T>) -> Result<Self, Self::Error> { + let locales = app.get_locales()?; + let root = app.get_root()?; + let plugins = &app.plugins; + + plugins + .functional_actions + .client_actions + .start + .run((), plugins.get_plugin_data())?; + + // We need to fetch some things from window variables + let render_cfg = + match WindowVariable::<HashMap<String, String>>::new_obj("__PERSEUS_RENDER_CFG") { + WindowVariable::Some(render_cfg) => render_cfg, + WindowVariable::None | WindowVariable::Malformed => { + return Err(ClientInvariantError::RenderCfg.into()) + } + }; + let global_state_ty = match WindowVariable::<Value>::new_obj("__PERSEUS_GLOBAL_STATE") { + WindowVariable::Some(val) => { + let state = TemplateState::from_value(val); + if state.is_empty() { + // TODO Since we have it to hand, just make sure the global state creator really + // wasn't going to create anything (otherwise fail + // immediately) + GlobalStateType::None + } else { + GlobalStateType::Server(state) + } + } + WindowVariable::None => GlobalStateType::None, + WindowVariable::Malformed => return Err(ClientInvariantError::GlobalState.into()), + }; + + Ok(Self { + // This instantiates as if for the engine-side, but it will rapidly be changed + router_state: RouterState::default(), + state_store: PageStateStore::new(app.pss_max_size), + global_state: GlobalState::new(global_state_ty), + translations_manager: ClientTranslationsManager::new(&locales), + // This will be filled out by a `.thaw()` call or HSR + frozen_app: Rc::new(RefCell::new(None)), + is_first: Cell::new(true), + current_view: create_rc_signal(View::empty()), + popup_error_view: create_rc_signal(View::empty()), + entities: app.entities, + locales, + render_cfg, + error_views: app.error_views, + root, + }) + } +} + +impl<G: Html> Reactor<G> { + /// Adds `self` to the given Sycamore scope as context. + /// + /// # Panics + /// This will panic if any other reactor is found in the context. + pub(crate) fn add_self_to_cx(self, cx: Scope) { + provide_context(cx, self); + } + /// Gets a [`Reactor`] out of the given Sycamore scope's context. + /// + /// You should never need to worry about this function panicking, since + /// your code will only ever run if a reactor is present. + pub fn from_cx(cx: Scope) -> &Self { + use_context::<Self>(cx) + } + /// Gets the currently active translator. + /// + /// On the browser-side, this will return `None` under some error + /// conditions, or before the initial load. + /// + /// On the engine-side, this will return `None` under certain error + /// conditions. + #[cfg(target_arch = "wasm32")] + pub fn try_get_translator(&self) -> Option<Translator> { + self.translations_manager.get_translator() + } + /// Gets the currently active translator. + /// + /// On the browser-side, this will return `None` under some error + /// conditions, or before the initial load. + /// + /// On the engine-side, this will return `None` under certain error + /// conditions. + #[cfg(not(target_arch = "wasm32"))] + pub fn try_get_translator(&self) -> Option<Translator> { + self.translator.clone() + } + /// Gets the currently active translator. Under some conditions, this will + /// panic: `.try_get_translator()` is available as a non-panicking + /// alternative. + /// + /// # Panics + /// Panics if used before the initial load on the browser, when there isn't + /// a translator yet, or if used on the engine-side when a translator is + /// not available (which will be inside certain error views). Note that + /// an engine-side panic would occur as the server is serving a request, + /// which will lead to the request not being fulfilled. + pub fn get_translator(&self) -> Translator { + self.try_get_translator().expect("translator not available") + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl<G: Html> Reactor<G> { + /// Initializes a new [`Reactor`] on the engine-side. + pub(crate) fn engine( + global_state: TemplateState, + mode: RenderMode<G>, + translator: Option<&Translator>, + ) -> Self { + Self { + state_store: PageStateStore::new(0), /* There will be no need for the state store on + * the + * server-side (but is still has to be + * accessible) */ + global_state: if !global_state.is_empty() { + GlobalState::new(GlobalStateType::Server(global_state)) + } else { + GlobalState::new(GlobalStateType::None) + }, + render_mode: mode, + translator: translator.cloned(), + } + } +} + +/// The possible states a window variable injected by the server/export process +/// can be found in. +#[cfg(target_arch = "wasm32")] +pub(crate) enum WindowVariable<T: Serialize + DeserializeOwned> { + /// It existed and coudl be deserialized into the correct type. + Some(T), + /// It was not present. + None, + /// It could not be deserialized into the correct type, or it was not + /// instantiated as the correct serialized type (e.g. expected to find a + /// string to be deserialized, found a boolean instead). + Malformed, +} +#[cfg(target_arch = "wasm32")] +impl<T: Serialize + DeserializeOwned> WindowVariable<T> { + /// Gets the window variable of the given name, attempting to fetch it as + /// the given type. This will only work with window variables that have + /// been serialized to strings from the given type `T`. + fn new_obj(name: &str) -> Self { + let val_opt = web_sys::window().unwrap().get(name); + let js_obj = match val_opt { + Some(js_obj) => js_obj, + None => return Self::None, + }; + // The object should only actually contain the string value that was injected + let val_str = match js_obj.as_string() { + Some(val_str) => val_str, + None => return Self::Malformed, + }; + let val_typed = match serde_json::from_str::<T>(&val_str) { + Ok(typed) => typed, + Err(_) => return Self::Malformed, + }; + + Self::Some(val_typed) + } +} +#[cfg(target_arch = "wasm32")] +impl WindowVariable<bool> { + /// Gets the window variable of the given name, attempting to fetch it as + /// the given type. This will only work with boolean window variables. + /// + /// While it may seem that a boolean cannot be 'malformed', it most + /// certainly can be if you think it is boolean, but it actually isn't! + /// + /// This is generally used internally for managing flags. + pub(crate) fn new_bool(name: &str) -> Self { + let val_opt = web_sys::window().unwrap().get(name); + let js_bool = match val_opt { + Some(js_bool) => js_bool, + None => return Self::None, + }; + // The object should only actually contain the boolean value that was injected + match js_bool.as_bool() { + Some(val) => Self::Some(val), + None => Self::Malformed, + } + } +} +#[cfg(target_arch = "wasm32")] +impl WindowVariable<String> { + /// Gets the window variable of the given name, attempting to fetch it as + /// the given type. This will only work with `String` window variables. + fn new_str(name: &str) -> Self { + let val_opt = web_sys::window().unwrap().get(name); + let js_str = match val_opt { + Some(js_str) => js_str, + None => return Self::None, + }; + // The object should only actually contain the boolean value that was injected + match js_str.as_string() { + Some(val) => Self::Some(val), + None => Self::Malformed, + } + } +} diff --git a/packages/perseus/src/reactor/render_mode.rs b/packages/perseus/src/reactor/render_mode.rs new file mode 100644 index 0000000000..32d511d131 --- /dev/null +++ b/packages/perseus/src/reactor/render_mode.rs @@ -0,0 +1,102 @@ +use crate::{ + error_views::{ErrorViews, ServerErrorData}, + errors::ServerError, + path::*, + state::TemplateState, + stores::ImmutableStore, +}; +use serde_json::Value; +use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc}; +use sycamore::web::Html; + +/// The status of a build-time render. +#[derive(Debug)] +pub(crate) enum RenderStatus { + /// The render is proceeding well. + Ok, + /// There was an error. + Err(ServerError), + /// The render was cancelled, since a widget couldn't be rendered at + /// build-time. + Cancelled, +} +impl Default for RenderStatus { + fn default() -> Self { + Self::Ok + } +} + +/// The different modes of rendering on the engine-side. On the browser-side, +/// there is only one mode of rendering. +/// +/// Ths render mode is primarily used to inform the non-delayed widgets of how +/// they should render. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug)] +pub(crate) enum RenderMode<G: Html> { + /// We're rendering at build-time. Any non-delayed widgets should render if + /// they are not going to alter the render properties of their caller. + /// Otherwise, they should silently fail the render and set the attached + /// [`Cell`] of this variant to `true` to inform the renderer. + Build { + /// Whether or not the render was cancelled due to a capsule being + /// unable to be rendered (having this determined *during* the + /// render avoids the need for the user to specify + /// all their pages' dependencies (which might be impossible with + /// incremental generation)). + render_status: Rc<RefCell<RenderStatus>>, + /// The render configuration for widgets. This will include both widgets + /// that are safe to be built at build-time, and widgets that + /// are not. + widget_render_cfg: HashMap<String, String>, + /// The app's immutable store. (This is cheap to clone.) + immutable_store: ImmutableStore, + /// An accumulator of the widget states involved in rendering this + /// template. We need to be able to collect these to later send + /// them to clients for hydration. + widget_states: Rc<RefCell<HashMap<String, (String, Value)>>>, + /// A list of widget paths that are either nonexistent, or able to be + /// incrementally generated. The build process should parse these and + /// try to build them all (failing entirely on any that actually don't + /// exist, or whose generation processes fail). + /// + /// This system means widget paths that aren't in build paths + /// declarations can still be used without template rescheduling + /// being required, which is just logical (Perseus should be + /// able to just figure this one out). + possibly_incremental_paths: Rc<RefCell<Vec<PathWithoutLocale>>>, + }, + /// We're rendering at request-time in order to determine what the + /// dependencies of this page/widget are. Each widget should check if + /// its state is available in the given map, proceeding with its + /// render if it is, or simply adding its route to a simple accumulator if + /// not. + /// + /// Once we get to the last layer of dependencies, the accumulator will come + /// out with nothing new, and then the return value is the + /// fully-rendered content! + Request { + /// The widget states and attached capsule names. Each of these is + /// fallible, and the widget component will render an + /// appropriate error page if necessary. + #[allow(clippy::type_complexity)] + widget_states: Rc<HashMap<PathMaybeWithLocale, Result<TemplateState, ServerErrorData>>>, + /// The app's error views. + error_views: Arc<ErrorViews<G>>, + /// A list of the paths to widgets that haven't yet been resolved in any + /// way. These will be deduplicated and then resolved in + /// parallel, along with having their states built. + /// + /// These paths do not contain the locale because a capsule from a + /// different locale can never be included. + unresolved_widget_accumulator: Rc<RefCell<Vec<PathWithoutLocale>>>, + }, + // These may have the same outcome for widgets, but they could in future be used for + // additional mode-specific data + /// We're rendering a head, where widgets are not allowed. + Head, + /// We're rendering an error, where widgets are not allowed. + Error, + /// We're rendering headers, where widgets are not allowed. + Headers, +} diff --git a/packages/perseus/src/reactor/start.rs b/packages/perseus/src/reactor/start.rs new file mode 100644 index 0000000000..595461e440 --- /dev/null +++ b/packages/perseus/src/reactor/start.rs @@ -0,0 +1,441 @@ +use super::Reactor; +use crate::{ + checkpoint, + error_views::ErrorPosition, + errors::ClientError, + reactor::InitialView, + router::{PageDisposer, PerseusRoute, RouteVerdict, RouterLoadState}, + template::BrowserNodeType, + utils::{render_or_hydrate, replace_head}, +}; +use sycamore::prelude::{create_effect, create_signal, on_mount, view, ReadSignal, Scope, View}; +use sycamore_futures::spawn_local_scoped; +use sycamore_router::{navigate_replace, HistoryIntegration, RouterBase}; +use web_sys::Element; + +// We don't want to bring in a styling library, so we do this the old-fashioned +// way! We're particularly comprehensive with these because the user could +// *potentially* stuff things up with global rules https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe +const ROUTE_ANNOUNCER_STYLES: &str = r#" + margin: -1px; + padding: 0; + border: 0; + clip: rect(0 0 0 0); + height: 1px; + width: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + word-wrap: normal; +"#; + +impl Reactor<BrowserNodeType> { + /// Sets the handlers necessary to run the event-driven components of + /// Perseus (in a reactive web framework, there are quite a few of + /// these). This should only be executed at the beginning of the + /// browser-side instantiation. + /// + /// This is internally responsible for fetching the initial load and + /// rendering it, starting the reactive cycle based on the given scope + /// that will handle subsequent loads and the like. + /// + /// This takes the app-level scope. + /// + /// As Sycamore works by starting a reactive cycle, rather than by calling a + /// function that never terminates, this will 'finish' as soon as the intial + /// load is ready. However, in cases of critical errors that have been + /// successfully displayed, the app-level scope should be disposed of. + /// If this should occur, this will return `false`, indicating that the + /// app was not successful. Note that server errors will not cause this, + /// and they will receive a router. This situation is very rare, and + /// affords a plugin action for analytics. + pub(crate) fn start<'a>(&'a self, cx: Scope<'a>) -> bool { + // We must be in the first load + assert!( + self.is_first.get(), + "attempted to instantiate perseus after first load" + ); + + // --- Route announcer --- + + let route_announcement = create_signal(cx, String::new()); + // Append the route announcer to the end of the document body + let document = web_sys::window().unwrap().document().unwrap(); + let announcer = document.create_element("p").unwrap(); + announcer.set_attribute("aria-live", "assertive").unwrap(); + announcer.set_attribute("role", "alert").unwrap(); + announcer + .set_attribute("style", ROUTE_ANNOUNCER_STYLES) + .unwrap(); + announcer.set_id("__perseus_route_announcer"); + let body_elem: Element = document.body().unwrap().into(); + body_elem + .append_with_node_1(&announcer.clone().into()) + .unwrap(); + // Update the announcer's text whenever the `route_announcement` changes + create_effect(cx, move || { + let ra = route_announcement.get(); + announcer.set_inner_html(&ra); + }); + + // Create a derived state for the route announcement + // We do this with an effect because we only want to update in some cases (when + // the new page is actually loaded) We also need to know if it's the first + // page (because we don't want to announce that, screen readers will get that + // one right) + + // This is not whether the first page has been loaded or not, it's whether or + // not we're still on it + let mut on_first_page = true; + let load_state = self.router_state.get_load_state_rc(); + create_effect(cx, move || { + if let RouterLoadState::Loaded { path, .. } = &*load_state.get() { + if on_first_page { + // This is the first load event, so the next one will be for a new page (or at + // least something that we should announce, if this page reloads then the + // content will change, that would be from thawing) + on_first_page = false; + } else { + // TODO Validate approach with reloading + // A new page has just been loaded and is interactive (this event only fires + // after all rendering and hydration is complete) + // Set the announcer to announce the title, falling back to the first `h1`, and + // then falling back again to the path + let document = web_sys::window().unwrap().document().unwrap(); + // If the content of the provided element is empty, this will transform it into + // `None` + let make_empty_none = |val: Element| { + let val = val.inner_html(); + if val.is_empty() { + None + } else { + Some(val) + } + }; + let title = document + .query_selector("title") + .unwrap() + .and_then(make_empty_none); + let announcement = match title { + Some(title) => title, + None => { + let first_h1 = document + .query_selector("h1") + .unwrap() + .and_then(make_empty_none); + match first_h1 { + Some(val) => val, + // Our final fallback will be the path + None => path.to_string(), + } + } + }; + + route_announcement.set(announcement); + } + } + }); + + // --- HSR and live reloading --- + + // This section handles live reloading and HSR freezing + // We used to have an indicator shared to the macros, but that's no longer used + #[cfg(all(feature = "live-reload", debug_assertions))] + { + use crate::state::Freeze; + // Set up a oneshot channel that we can use to communicate with the WS system + // Unfortunately, we can't share senders/receivers around without bringing in + // another crate And, Sycamore's `RcSignal` doesn't like being put into + // a `Closure::wrap()` one bit + let (live_reload_tx, live_reload_rx) = futures::channel::oneshot::channel(); + sycamore_futures::spawn_local_scoped(cx, async move { + match live_reload_rx.await { + // This will trigger only once, and then can't be used again + // That shouldn't be a problem, because we'll reload immediately + Ok(_) => { + #[cfg(all(feature = "hsr"))] + { + let frozen_state = self.freeze(); + Self::hsr_freeze(frozen_state).await; + } + crate::state::force_reload(); + // We shouldn't ever get here unless there was an error, + // the entire page will be fully + // reloaded + } + _ => (), + } + }); + + // If live reloading is enabled, connect to the server now + // This doesn't actually perform any reloading or the like, it just signals + // places that have access to the render context to do so (because we need that + // for state freezing/thawing) + crate::state::connect_to_reload_server(live_reload_tx); + } + + // This handles HSR thawing + #[cfg(all(feature = "hsr", debug_assertions))] + { + sycamore_futures::spawn_local_scoped(cx, async move { + // We need to make sure we don't run this more than once, because that would + // lead to a loop It also shouldn't run on any pages after the + // initial load + if self.is_first.get() { + self.is_first.set(false); + self.hsr_thaw().await; + } + }); + }; + + // --- Error handlers --- + + let popup_error_disposer = PageDisposer::default(); + // Broken out for ease if the reactor can't be created + let popup_error_root = Self::get_popup_err_elem(); + // Now set up the handlers to actually render popup errors (the scope will keep + // reactivity going as long as it isn't dropped). Popup errors do *not* + // get access to a router or the like. Ever time `popup_err_view` is + // updated, this will update too. + render_or_hydrate( + cx, + view! { cx, + (*self.popup_error_view.get()) + }, + popup_error_root, + true, // Popup errors are always browser-side-only, so force a full render + ); + + // --- Initial load --- + + // We handle the disposer for the page-wide view, without worrying about + // widgets, because they're all in child scopes of the page scope, + // meaning they will be automatically disposed of when the page disposer + // is called. + let page_disposer = PageDisposer::default(); + // Get the root we'll be injecting the router into + let root = web_sys::window() + .unwrap() + .document() + .unwrap() + .query_selector(&format!("#{}", &self.root)) + .unwrap() + .unwrap(); + // Unless we hear otherwise, we'll hydrate the main view by default (but some + // errors should trigger a full render). This only matters for the + // initial load, since view changes are done reactively after that. + let mut force_render = false; + // Get the initial load so we have something to put inside the root. Usually, we + // can simply report errors, but, because we don't actually have a place to put + // page-wide errors yet, we need to know what this will return so we know if we + // should proceed. + let (starting_view, is_err) = match self.get_initial_view(cx) { + Ok(InitialView::View(view, disposer)) => { + // SAFETY: There's nothing in there right now, and we know that for sure + // because it's the initial load (asserted above). Also, we're in the app-level + // scope. + unsafe { + page_disposer.update(disposer); + } + + (view, false) + } + // On a redirect, return a view that just redirects straight away (of course, + // this will be created inside a router, so everything works nicely) + Ok(InitialView::Redirect(dest)) => { + force_render = true; + ( + view! { cx, + ({ + let dest = dest.clone(); + on_mount(cx, move || { + navigate_replace(&dest); + }); + View::empty() + }) + }, + false, + ) + } + // We still need the page-wide view + Err(err @ ClientError::ServerError { .. }) => { + // Rather than worrying about multi-file invariants, just do the error + // handling manually for sanity + let (head_str, body_view, disposer) = + self.error_views.handle(cx, err, ErrorPosition::Page); + replace_head(&head_str); + + // SAFETY: There's nothing in there right now, and we know that for sure + // because it's the initial load (asserted above). Also, we're in the app-level + // scope. + unsafe { + page_disposer.update(disposer); + } + + // For apps using exporting, it's very possible that the prerendered may be + // unlocalized, and this may be localized. Hence, we clear the contents. + force_render = true; + (body_view, true) + } + // Popup error: we will not create a router, terminating immediately + // and instructing the caller to dispose of the scope + Err(err) => { + // Rather than worrying about multi-file invariants, just do the error + // handling manually for sanity + let (_, body_view, _disposer) = + self.error_views.handle(cx, err, ErrorPosition::Popup); + self.popup_error_view.set(body_view); // Popups never hydrate + + // Signal the top-level disposer, which will also call the child scope disposer + // ignored above + return false; + } + }; + self.current_view.set(starting_view); + + // --- Reload commander --- + + // This allows us to not run the subsequent load code on the initial load (we + // need a separate one for the reload commander) + let is_initial_reload_commander = create_signal(cx, true); + let router_state = &self.router_state; + let page_disposer_2 = page_disposer.clone(); + let popup_error_disposer_2 = popup_error_disposer.clone(); + create_effect(cx, move || { + router_state.reload_commander.track(); + // These use `RcSignal`s, so there's still only one actual disposer for each + let page_disposer_2 = page_disposer_2.clone(); + let popup_error_disposer_2 = popup_error_disposer_2.clone(); + + // Using a tracker of the initial state separate to the main one is fine, + // because this effect is guaranteed to fire on page load (they'll both be set) + if *is_initial_reload_commander.get_untracked() { + is_initial_reload_commander.set(false); + } else { + // Get the route verdict and re-run the function we use on route changes + // This has to be untracked, otherwise we get an infinite loop that will + // actually break client browsers (I had to manually kill Firefox...) + // TODO Investigate how the heck this actually caused an infinite loop... + let verdict = router_state.get_last_verdict(); + let verdict = match verdict { + Some(verdict) => verdict, + // If the first page hasn't loaded yet, terminate now + None => return, + }; + spawn_local_scoped(cx, async move { + // Get the subsequent view and handle errors + match self.get_subsequent_view(cx, verdict.clone()).await { + Ok((view, disposer)) => { + self.current_view.set(view); + // SAFETY: We're outside the old page's scope + unsafe { + page_disposer_2.update(disposer); + } + } + Err(err) => { + // Any errors should be gracefully reported, and their disposers + // placed into the correct `Signal` for future managament + let (disposer, pagewide) = self.report_err(cx, err); + // SAFETY: We're outside the old error/page's scope + if pagewide { + unsafe { + page_disposer_2.update(disposer); + } + } else { + unsafe { + popup_error_disposer_2.clone().update(disposer); + } + } + } + }; + }); + } + }); + + // --- Router! --- + + if is_err { + checkpoint("error"); + } else { + checkpoint("page_interactive"); + } + + // Now set up the full router + // let popup_error_disposer_2 = popup_error_disposer.clone(); + render_or_hydrate( + cx, + view! { cx, + RouterBase( + integration = HistoryIntegration::new(), + // This will be immediately updated and fixed up + route = PerseusRoute { + // This is completely invalid, but will never be read + verdict: RouteVerdict::NotFound { locale: "xx-XX".to_string() }, + cx: Some(cx), + }, + view = move |cx, route: &ReadSignal<PerseusRoute>| { + // Do this on every update to the route, except the first time, when we'll use the initial load + create_effect(cx, move || { + route.track(); + // These use `RcSignal`s, so there's still only one actual disposer for each + let page_disposer_2 = page_disposer.clone(); + let popup_error_disposer_2 = popup_error_disposer.clone(); + + if self.is_first.get() { + self.is_first.set(false); + } else { + spawn_local_scoped(cx, async move { + let route = route.get(); + let verdict = route.get_verdict(); + + // Get the subsequent view and handle errors + match self.get_subsequent_view(cx, verdict.clone()).await { + Ok((view, disposer)) => { + self.current_view.set(view); + // SAFETY: We're outside the old page's scope + unsafe { page_disposer_2.update(disposer); } + } + Err(err) => { + // Any errors should be gracefully reported, and their disposers + // placed into the correct `Signal` for future managament + let (disposer, pagewide) = self.report_err(cx, err); + // SAFETY: We're outside the old error/page's scope + if pagewide { + unsafe { page_disposer_2.update(disposer); } + } else { + unsafe { popup_error_disposer_2.clone().update(disposer); } + } + } + }; + }); + } + }); + + // This template is reactive, and will be updated as necessary + view! { cx, + (*self.current_view.get()) + } + } + ) + }, + root, + // BUG Hydration is currently disabled at the system level due to critical bugs + true, + /* force_render, */ /* Depending on whether or not there's an error, we might force + * a full render */ + ); + + // If we successfully got here, the app is running! + true + } + + /// Gets the element for popup errors (used in both full startup and + /// critical failures). + /// + /// This is created on the engine-side to avoid hydration issues. + pub(crate) fn get_popup_err_elem() -> Element { + let document = web_sys::window().unwrap().document().unwrap(); + // If we can't get the error container, it's logical to panic + document.get_element_by_id("__perseus_popup_error").unwrap() + } +} diff --git a/packages/perseus/src/reactor/state.rs b/packages/perseus/src/reactor/state.rs new file mode 100644 index 0000000000..92cb07e2e8 --- /dev/null +++ b/packages/perseus/src/reactor/state.rs @@ -0,0 +1,436 @@ +use super::Reactor; +use crate::{ + errors::*, + path::*, + state::{AnyFreeze, MakeRx, MakeUnrx, TemplateState}, +}; +#[cfg(target_arch = "wasm32")] +use crate::{ + router::RouterLoadState, + state::{Freeze, FrozenApp, ThawPrefs}, +}; +use serde::{de::DeserializeOwned, Serialize}; +#[cfg(target_arch = "wasm32")] +use sycamore::prelude::Scope; +use sycamore::web::Html; +#[cfg(target_arch = "wasm32")] +use sycamore_router::navigate; + +// Explicitly prevent the user from trying to freeze on the engine-side +#[cfg(target_arch = "wasm32")] +impl<G: Html> Freeze for Reactor<G> { + fn freeze(&self) -> String { + // This constructs a `FrozenApp`, which has everything the thawing reactor will + // need + let frozen_app = FrozenApp { + // `GlobalStateType` -> `FrozenGlobalState` + global_state: (&*self.global_state.0.borrow()).into(), + route: match &*self.router_state.get_load_state_rc().get_untracked() { + RouterLoadState::Loaded { path, .. } => Some(path.clone()), + // It would be impressive to manage this timing, but it's fine to go to the route we + // were in the middle of loading when we thaw + RouterLoadState::Loading { path, .. } => Some(path.clone()), + // If we encounter this during re-hydration, we won't try to set the URL in the + // browser + RouterLoadState::ErrorLoaded { .. } => None, + RouterLoadState::Server => None, + }, + state_store: self.state_store.freeze_to_hash_map(), + }; + serde_json::to_string(&frozen_app).unwrap() + } +} + +#[cfg(target_arch = "wasm32")] +impl<G: Html> Reactor<G> { + /// Commands Perseus to 'thaw' the app from the given frozen state. You'll + /// also need to provide preferences for thawing, which allow you to control + /// how different pages should prioritize frozen state over existing (or + /// *active*) state. Once you call this, assume that any following logic + /// will not run, as this may navigate to a different route in your app. How + /// you get the frozen state to supply to this is up to you. + /// + /// If the app has already been thawed from a previous frozen state, any + /// state used from that will be considered *active* for this thawing. + /// + /// This will return an error if the frozen state provided is invalid. + /// However, if the frozen state for an individual page is invalid, it will + /// be silently ignored in favor of either the active state or the + /// server-provided state. + /// + /// Note that any existing frozen app will be overriden by this. + /// + /// If the app was last frozen while on an error page, this will not attempt + /// to change the current route. + pub fn thaw(&self, new_frozen_app: &str, thaw_prefs: ThawPrefs) -> Result<(), ClientError> { + self._thaw(new_frozen_app, thaw_prefs, false) + } + /// Internal underlying thaw logic (generic over HSR). + pub(super) fn _thaw( + &self, + new_frozen_app: &str, + thaw_prefs: ThawPrefs, + is_hsr: bool, + ) -> Result<(), ClientError> { + // This won't check the data model, just that it is valid in some Perseus app + // that could exist (therefore fine with HSR) + let new_frozen_app: FrozenApp = serde_json::from_str(new_frozen_app) + .map_err(|err| ClientThawError::InvalidFrozenApp { source: err })?; + let route = new_frozen_app.route.clone(); + // Update our current frozen app + let mut frozen_app = self.frozen_app.borrow_mut(); + *frozen_app = Some((new_frozen_app, thaw_prefs, is_hsr)); + // Better safe than sorry + drop(frozen_app); + + if let Some(frozen_route) = route { + let curr_route = match &*self.router_state.get_load_state_rc().get_untracked() { + // If we've loaded a page, or we're about to, only change the route if necessary + RouterLoadState::Loaded { path, .. } + | RouterLoadState::Loading { path, .. } + | RouterLoadState::ErrorLoaded { path } => path.clone(), + // Since this function is only defined on the browser-side, this should + // be completely impossible (note that the user can't change the router + // state manually) + RouterLoadState::Server => unreachable!(), + }; + // If we're on the same page, just reload, otherwise go to the frozen route + if curr_route == frozen_route { + // We need to do this to get the new frozen state (dependent on thaw prefs) + self.router_state.reload(); + } else { + navigate(&frozen_route); + } + } else { + self.router_state.reload(); + } + + Ok(()) + } + + /// Preloads the given URL from the server and caches it, preventing + /// future network requests to fetch that page. Localization will be + /// handled automatically. + /// + /// This function automatically defers the asynchronous preloading + /// work to a browser future for convenience. If you would like to + /// access the underlying future, use `.try_preload()` instead. + /// + /// To preload a widget, you must prefix its path with `__capsule/`. + /// + /// # Panics + /// This function will panic if any errors occur in preloading, such as + /// the route being not found, or not localized. If the path you're + /// preloading is not hardcoded, use `.try_preload()` instead. + // Conveniently, we can use the lifetime mechanics of knowing that the render + // context is registered on the given scope to ensure that the future works + // out + pub fn preload<'a, 'b: 'a>(&'b self, cx: Scope<'a>, url: &str) { + use fmterr::fmt_err; + let url = url.to_string(); + + sycamore_futures::spawn_local_scoped(cx, async move { + if let Err(err) = self.try_preload(&url).await { + panic!("{}", fmt_err(&err)); + } + }); + } + /// Preloads the given URL from the server and caches it for the current + /// route, preventing future network requests to fetch that page. On a + /// route transition, this will be removed. Localization will be + /// handled automatically. + /// + /// WARNING: the route preloading system is under heavy construction at + /// present! + /// + /// This function automatically defers the asynchronous preloading + /// work to a browser future for convenience. If you would like to + /// access the underlying future, use `.try_route_preload()` instead. + /// + /// To preload a widget, you must prefix its path with `__capsule/`. + /// + /// # Panics + /// This function will panic if any errors occur in preloading, such as + /// the route being not found, or not localized. If the path you're + /// preloading is not hardcoded, use `.try_route_preload()` instead. + // Conveniently, we can use the lifetime mechanics of knowing that the render + // context is registered on the given scope to ensure that the future works + // out + pub fn route_preload<'a, 'b: 'a>(&'b self, cx: Scope<'a>, url: &str) { + use fmterr::fmt_err; + let url = url.to_string(); + + sycamore_futures::spawn_local_scoped(cx, async move { + if let Err(err) = self.try_route_preload(&url).await { + panic!("{}", fmt_err(&err)); + } + }); + } + /// A version of `.preload()` that returns a future that can resolve to an + /// error. If the path you're preloading is not hardcoded, you should + /// use this. Localization will be + /// handled automatically. + /// + /// To preload a widget, you must prefix its path with `__capsule/`. + pub async fn try_preload(&self, url: &str) -> Result<(), ClientError> { + self._preload(url, false).await + } + /// A version of `.route_preload()` that returns a future that can resolve + /// to an error. If the path you're preloading is not hardcoded, you + /// should use this. Localization will be + /// handled automatically. + /// + /// To preload a widget, you must prefix its path with `__capsule/`. + pub async fn try_route_preload(&self, url: &str) -> Result<(), ClientError> { + self._preload(url, true).await + } + /// Preloads the given URL from the server and caches it, preventing + /// future network requests to fetch that page. Localization will be + /// handled automatically. + /// + /// To preload a widget, you must prefix its path with `__capsule/`. + async fn _preload(&self, path: &str, is_route_preload: bool) -> Result<(), ClientError> { + use crate::router::{match_route, FullRouteVerdict}; + + // It is reasonable to assume that this function will not be called before the + // instantiation of a translator + let locale = self.get_translator().get_locale(); + let full_path = PathMaybeWithLocale::new(&PathWithoutLocale(path.to_string()), &locale); + + let path_segments = full_path + .split('/') + .filter(|s| !s.is_empty()) + .collect::<Vec<&str>>(); // This parsing is identical to the Sycamore router's + // Get a route verdict on this so we know where we're going (this doesn't modify + // the router state) + let verdict = match_route( + &path_segments, + &self.render_cfg, + &self.entities, + &self.locales, + ); + // Make sure we've got a valid verdict (otherwise the user should be told there + // was an error) + let route_info = match verdict.into_full(&self.entities) { + FullRouteVerdict::Found(info) => info, + FullRouteVerdict::NotFound { .. } => { + return Err(ClientPreloadError::PreloadNotFound { + path: path.to_string(), + } + .into()) + } + FullRouteVerdict::LocaleDetection(_) => { + return Err(ClientPreloadError::PreloadLocaleDetection { + path: path.to_string(), + } + .into()) + } + }; + + // We just needed to acquire the arguments to this function + self.state_store + .preload( + // We want an unlocalized path, which will be amalgamated with the locale for the + // key + &route_info.path, + &route_info.locale, + &route_info.entity.get_path(), + route_info.was_incremental_match, + is_route_preload, + // While we might be preloading a widget, this just controls asset types, and, + // since this function is intended for end user abstractions, this should always + // use `AssetType::Preload` + false, + ) + .await + } +} + +// These methods are used for acquiring the state of pages on both the +// browser-side and the engine-side +impl<G: Html> Reactor<G> { + /// Gets the intermediate state type for the given page by evaluating active + /// and frozen state to see if anything else is available, reverting to + /// the provided state from the server if necessary. + /// + /// This will return an invariant error if the provided server state is + /// invalid, since it's assumed to have actually come from the server. + /// It is also expected that the given path does actually take state! + /// + /// This should not be used for capsules! + pub(crate) fn get_page_state<S>( + &self, + url: &PathMaybeWithLocale, + server_state: TemplateState, + ) -> Result<S::Rx, ClientError> + where + S: MakeRx + Serialize + DeserializeOwned + 'static, + S::Rx: MakeUnrx<Unrx = S> + AnyFreeze + Clone, + { + if let Some(held_state) = self.get_held_state::<S>(url, false)? { + Ok(held_state) + } else if server_state.is_empty() { + Err(ClientInvariantError::NoState.into()) + } else { + // Fall back to the state we were given, first + // giving it a type (this just sets a phantom type parameter) + let typed_state = server_state.change_type::<S>(); + // This attempts a deserialization from a `Value`, which could fail + let unrx = typed_state + .into_concrete() + .map_err(|err| ClientInvariantError::InvalidState { source: err })?; + let rx = unrx.make_rx(); + // Add that to the state store as the new active state + self.state_store.add_state(url, rx.clone(), false)?; + + Ok(rx) + } + } + /// Registers a page/widget as definitely taking no state, which allows it + /// to be cached fully, preventing unnecessary network requests. Any + /// future attempt to set state will lead to errors (with exceptions for + /// HSR). + pub fn register_no_state(&self, url: &PathMaybeWithLocale, is_widget: bool) { + self.state_store.set_state_never(url, is_widget); + } + + /// Determines if the given path (page or capsule) should use the state + /// given by the server, or whether it has other state in the + /// frozen/active state systems. If the latter is true, + /// this will instantiate them appropriately and return them. If this + /// returns `None`, the server-provided state should be used. + /// + /// This needs to know if it's a widget or a page so the state can be + /// appropriately registered in the state store if necessary. + /// + /// To understand the exact logic chain this uses, please refer to the + /// flowchart of the Perseus reactive state platform in the book. + /// + /// Note: on the engine-side, there is no such thing as frozen state, and + /// the active state will always be empty, so this will simply return + /// `None`. + #[cfg(target_arch = "wasm32")] + pub(super) fn get_held_state<S>( + &self, + url: &PathMaybeWithLocale, + is_widget: bool, + ) -> Result<Option<S::Rx>, ClientError> + where + S: MakeRx + Serialize + DeserializeOwned, + S::Rx: MakeUnrx<Unrx = S> + AnyFreeze + Clone, + { + // See if we can get both the active and frozen states + let frozen_app_full = self.frozen_app.borrow(); + if let Some((_, thaw_prefs, _)) = &*frozen_app_full { + // Check against the thaw preferences if we should prefer frozen state over + // active state + if thaw_prefs.page.should_prefer_frozen_state(url) { + drop(frozen_app_full); + // We'll fall back to active state if no frozen state is available + match self.get_frozen_state_and_register::<S>(url, is_widget)? { + Some(state) => Ok(Some(state)), + None => Ok(self.get_active_state::<S>(url)), + } + } else { + drop(frozen_app_full); + // We're preferring active state, but we'll fall back to frozen state if none is + // available + match self.get_active_state::<S>(url) { + Some(state) => Ok(Some(state)), + None => self.get_frozen_state_and_register::<S>(url, is_widget), + } + } + } else { + // No frozen app exists, so we of course shouldn't prioritize it + Ok(self.get_active_state::<S>(url)) + } + } + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn get_held_state<S>( + &self, + _url: &PathMaybeWithLocale, + _is_widget: bool, + ) -> Result<Option<S::Rx>, ClientError> + where + S: MakeRx, + S::Rx: MakeUnrx<Unrx = S> + AnyFreeze + Clone, + { + Ok(None) + } + + /// Attempts to the get the active state for a page or widget. Of course, + /// this does not register anything in the state store. + #[cfg(target_arch = "wasm32")] + fn get_active_state<S>(&self, url: &PathMaybeWithLocale) -> Option<S::Rx> + where + S: MakeRx, + S::Rx: MakeUnrx<Unrx = S> + AnyFreeze + Clone, + { + self.state_store.get_state::<S::Rx>(url) + } + /// Attempts to extract the frozen state for the given page from any + /// currently registered frozen app, registering what it finds. This + /// assumes that the thaw preferences have already been accounted for. + #[cfg(target_arch = "wasm32")] + fn get_frozen_state_and_register<S>( + &self, + url: &PathMaybeWithLocale, + is_widget: bool, + ) -> Result<Option<S::Rx>, ClientError> + where + S: MakeRx + Serialize + DeserializeOwned, + S::Rx: MakeUnrx<Unrx = S> + AnyFreeze + Clone, + { + let frozen_app_full = self.frozen_app.borrow(); + if let Some((frozen_app, _, is_hsr)) = &*frozen_app_full { + #[cfg(not(all(debug_assertions, feature = "hsr")))] + assert!( + !is_hsr, + "attempted to invoke hsr-style thaw in non-hsr environment" + ); + // Get the serialized and unreactive frozen state from the store + match frozen_app.state_store.get(&url) { + Some(state_str) => { + // Deserialize into the unreactive version + let unrx = match serde_json::from_str::<S>(state_str) { + Ok(unrx) => unrx, + // A corrupted frozen state should explicitly bubble up to be an error, + // *unless* this is HSR, in which case the data model has just been changed, + // and we should move on + Err(_) if *is_hsr => return Ok(None), + Err(err) => { + return Err(ClientThawError::InvalidFrozenState { source: err }.into()) + } + }; + // This returns the reactive version of the unreactive version of `R`, which + // is why we have to make everything else do the same + // Then we convince the compiler that that actually is `R` with the + // ludicrous trait bound at the beginning of this function + let rx = unrx.make_rx(); + // Now add the reactive version to the state store (see the documentation + // for this method for HSR caveats, and why we ignore the error in HSR mode) + match self.state_store.add_state(url, rx.clone(), is_widget) { + Ok(_) => (), + // This means the user has removed state from an entity that previously had + // it, and that's fine + Err(_) if *is_hsr => return Ok(None), + Err(err) => return Err(err), + }; + + // Now we should remove this from the frozen state so we don't fall back to + // it again + drop(frozen_app_full); + let mut frozen_app_val = self.frozen_app.take().unwrap(); // We're literally in a conditional that checked this + frozen_app_val.0.state_store.remove(url); + let mut frozen_app = self.frozen_app.borrow_mut(); + *frozen_app = Some(frozen_app_val); + + Ok(Some(rx)) + } + None => Ok(None), + } + } else { + Ok(None) + } + } +} diff --git a/packages/perseus/src/reactor/subsequent_load.rs b/packages/perseus/src/reactor/subsequent_load.rs new file mode 100644 index 0000000000..e519ef9b98 --- /dev/null +++ b/packages/perseus/src/reactor/subsequent_load.rs @@ -0,0 +1,195 @@ +use serde_json::Value; +use sycamore::{ + prelude::{create_scope, Scope, ScopeDisposer}, + view::View, + web::Html, +}; + +use crate::{ + checkpoint, + errors::{AssetType, ClientError, ClientInvariantError}, + i18n::detect_locale, + page_data::PageDataPartial, + path::PathMaybeWithLocale, + router::{FullRouteInfo, FullRouteVerdict, RouteVerdict, RouterLoadState}, + state::{PssContains, TemplateState}, + utils::{fetch, get_path_prefix_client, replace_head}, +}; + +use super::Reactor; + +impl<G: Html> Reactor<G> { + /// Gets the subsequent view, based on the given verdict. + /// + /// Note that 'server errors' as constructed by this function are + /// constructed here, not deserialized from provided data. + /// + /// # Panics + /// This function will panic on a locale redirection if a router has not + /// been created on the given scope. + /// + /// This function will also panic if the given route verdict stores a + /// template name that is not known to this reactor (i.e. it must have + /// been generated by `match_route`). + pub(crate) async fn get_subsequent_view<'a>( + &self, + cx: Scope<'a>, + verdict: RouteVerdict, + ) -> Result<(View<G>, ScopeDisposer<'a>), ClientError> { + checkpoint("router_entry"); + // We'll need this for setting the router load state later + let slim_verdict = verdict.clone(); + + match &verdict.into_full(&self.entities) { + FullRouteVerdict::Found(FullRouteInfo { + path, + entity, + locale, + was_incremental_match, + }) => { + let full_path = PathMaybeWithLocale::new(&path, &locale); + // Update the router state + self.router_state.set_load_state(RouterLoadState::Loading { + template_name: entity.get_path(), + path: full_path.clone(), + }); + self.router_state.set_last_verdict(slim_verdict); + + // Before we fetch anything, first check if there's an entry in the PSS already + // (if there is, we can avoid a network request) + let page_data = match self.state_store.contains(&full_path) { + // We only have one part of the puzzle (or nothing at all), and no guarantee + // that the other doesn't exist, so we'll have to check with + // the server to be safe. Remember that this function + // can't be used with widgets! + PssContains::State | PssContains::Head | PssContains::None => { + // Get the static page data (head and state) + let asset_url = format!( + "{}/.perseus/page/{}/{}.json?entity_name={}&was_incremental_match={}", + get_path_prefix_client(), + locale, + **path, + entity.get_path(), + was_incremental_match + ); + // If this doesn't exist, then it's a 404 (we went here by explicit + // navigation, but it may be an unservable ISR page + // or the like) + let page_data_str = fetch(&asset_url, AssetType::Page).await?; + match &page_data_str { + Some(page_data_str) => { + // All good, deserialize the page data + let page_data = + serde_json::from_str::<PageDataPartial>(&page_data_str); + match page_data { + Ok(page_data) => { + // Add the head to the PSS for future use (we make + // absolutely no + // assumptions about state and leave that to the macros) + self.state_store.add_head( + &full_path, + page_data.head.to_string(), + false, + ); + page_data + } + // If the page failed to serialize, it's a server error + Err(err) => { + return Err(ClientInvariantError::InvalidState { + source: err, + } + .into()) + } + } + } + // This indicates the fetch found a 404 (any other errors were + // propagated by `?`) + None => { + return Err(ClientError::ServerError { + status: 404, + message: "page not found".to_string(), + }) + } + } + } + // We have everything locally, so we can move right ahead! + PssContains::All => PageDataPartial { + // This will never be parsed, because the template closures use the active + // state preferentially, whose existence we verified + // by getting here + state: Value::Null, + head: self.state_store.get_head(&full_path).unwrap(), + }, + // We only have document metadata, but the page definitely takes no state, so + // we're fine + PssContains::HeadNoState => PageDataPartial { + state: Value::Null, + head: self.state_store.get_head(&full_path).unwrap(), + }, + // The page's data has been preloaded at some other time + PssContains::Preloaded => { + let page_data = self.state_store.get_preloaded(&full_path).unwrap(); + // Register the head, otherwise it will never be registered and the page + // will never properly show up in the PSS (meaning + // future preload calls will go through, creating + // unnecessary network requests) + self.state_store + .add_head(&full_path, page_data.head.to_string(), false); + page_data + } + }; + // Interpolate the metadata directly into the document's `<head>` + replace_head(&page_data.head); + + // Now update the translator (this will do nothing if the user hasn't switched + // locales). Importantly, if this returns an error, the error + // views will almost certainly get the old translator. Because + // this will be registered as an internal error as well, + // that means we'll probably get a popup, which is much better UX than an error + // page on `/fr-FR/foo` in Spanish. + self.translations_manager + .set_translator_for_locale(&locale) + .await?; + + let template_name = entity.get_path(); + // Pre-emptively update the router state + checkpoint("page_interactive"); + // Update the router state + self.router_state.set_load_state(RouterLoadState::Loaded { + template_name, + path: full_path.clone(), + }); + // Now return the view that should be rendered + let (view, disposer) = entity.render_for_template_client( + full_path, + TemplateState::from_value(page_data.state), + cx, + )?; + + Ok((view, disposer)) + } + // For subsequent loads, this should only be possible if the dev forgot `link!()` + // TODO Debug assertion that this doesn't happen perhaps? + FullRouteVerdict::LocaleDetection(path) => { + let dest = detect_locale(path.clone(), &self.locales); + // Since this is only for subsequent loads, we know the router is instantiated + // This shouldn't be a replacement navigation, since the user has deliberately + // navigated here + sycamore_router::navigate(&dest); + // We'll ever get here + Ok((View::empty(), create_scope(|_| {}))) + } + FullRouteVerdict::NotFound { .. } => { + checkpoint("not_found"); + + // Neatly return a `ClientError::ServerError`, which will be displayed somehow + // by the caller (hopefully as a full-page view, but that will depend on the + // user's error view logic) + Err(ClientError::ServerError { + status: 404, + message: "page not found".to_string(), + }) + } + } + } +} diff --git a/packages/perseus/src/reactor/widget_state.rs b/packages/perseus/src/reactor/widget_state.rs new file mode 100644 index 0000000000..4afd37f04c --- /dev/null +++ b/packages/perseus/src/reactor/widget_state.rs @@ -0,0 +1,355 @@ +#[cfg(target_arch = "wasm32")] +use std::sync::Arc; + +use super::Reactor; +use crate::{ + error_views::ServerErrorData, + errors::{ClientError, ClientInvariantError}, + path::*, + state::{AnyFreeze, MakeRx, MakeUnrx, PssContains, TemplateState, UnreactiveState}, +}; +use serde::{de::DeserializeOwned, Serialize}; +use sycamore::{ + prelude::{create_child_scope, create_ref, BoundedScope, Scope, ScopeDisposer}, + view::View, + web::Html, +}; + +#[cfg(target_arch = "wasm32")] +use crate::template::PreloadInfo; +#[cfg(target_arch = "wasm32")] +use sycamore::prelude::create_signal; +#[cfg(target_arch = "wasm32")] +use sycamore_futures::spawn_local_scoped; + +impl<G: Html> Reactor<G> { + /// Gets the view and disposer for the given widget path. This will perform + /// asynchronous fetching as needed to fetch state from the server, and + /// will also handle engine-side state pass-through. This function will + /// propagate as many errors as it can, though those occurring inside a + /// `spawn_local_scoped` environment will be resolved to error views. + /// + /// This is intended for use with widgets that use reactive state. See + /// `.get_unreactive_widget_view()` for widgets that use unreactive + /// state. + // HRTB explanation: 'a = 'app, but the compiler hates that. + pub(crate) fn get_widget_view<'a, S, F, P: Clone + 'static>( + &'a self, + app_cx: Scope<'a>, + path: PathMaybeWithLocale, + #[allow(unused_variables)] caller_path: PathMaybeWithLocale, + #[cfg(target_arch = "wasm32")] capsule_name: String, + template_state: TemplateState, // Empty on the browser-side + props: P, + #[cfg(target_arch = "wasm32")] preload_info: PreloadInfo, + view_fn: F, + #[cfg(target_arch = "wasm32")] fallback_fn: &Arc<dyn Fn(Scope, P) -> View<G> + Send + Sync>, + ) -> Result<(View<G>, ScopeDisposer<'a>), ClientError> + where + // Note: these bounds replicate those for `.view_with_state()`, except the app lifetime is + // known + F: for<'app, 'child> Fn(BoundedScope<'app, 'child>, &'child S::Rx, P) -> View<G> + + Send + + Sync + + 'static, + S: MakeRx + Serialize + DeserializeOwned + 'static, + S::Rx: MakeUnrx<Unrx = S> + AnyFreeze + Clone, + { + match self.get_widget_state_no_fetch::<S>(&path, template_state)? { + Some(intermediate_state) => { + let mut view = View::empty(); + let disposer = create_child_scope(app_cx, |child_cx| { + // We go back from the unreactive state type wrapper to the base type (since + // it's unreactive) + view = view_fn(child_cx, create_ref(child_cx, intermediate_state), props); + }); + Ok((view, disposer)) + } + // We need to asynchronously fetch the state from the server, which doesn't work + // ergonomically with the rest of the code, so we just break out entirely + #[cfg(target_arch = "wasm32")] + None => { + return { + let view = create_signal(app_cx, View::empty()); + + let fallback_fn = fallback_fn.clone(); + let disposer = create_child_scope(app_cx, |child_cx| { + // We'll render the fallback view in the meantime (which `PerseusApp` + // guarantees to be defined for capsules) + view.set((fallback_fn)(child_cx, props.clone())); + // Note: this uses `child_cx`, meaning the fetch will be aborted if the user + // goes to another page (when this page is cleaned + // up, including all child scopes) + let capsule_name = capsule_name.clone(); + spawn_local_scoped(child_cx, async move { + // Any errors that occur in here will be converted into proper error + // views using the reactor (it's not the + // nicest handling pattern, but in a future + // like this, it's the best we can do) + let final_view = { + let path_without_locale = + PathWithoutLocale(match preload_info.locale.as_str() { + "xx-XX" => path.to_string(), + locale => path + .strip_prefix(&format!("{}/", locale)) + .unwrap() + .to_string(), + }); + // We can simply use the preload system to perform the fetching + match self + .state_store + .preload( + &path_without_locale, + &preload_info.locale, + &capsule_name, + preload_info.was_incremental_match, + false, // Don't use the route preloading system + true, // This is a widget + ) + .await + { + // If that succeeded, we can use the same logic as before, and + // we know it can't return `Ok(None)` + // this time! We're in the browser, so we can just use an empty + // template state, rather than + // cloning the one we've been given (which is empty anyway). + Ok(()) => match self.get_widget_state_no_fetch::<S>( + &path, + TemplateState::empty(), + ) { + Ok(Some(intermediate_state)) => { + // Declare the relationship between the widget and its + // caller + self.state_store + .declare_dependency(&path, &caller_path); + + view_fn( + child_cx, + create_ref(child_cx, intermediate_state), + props, + ) + } + Ok(None) => unreachable!(), + Err(err) => self.error_views.handle_widget(err, child_cx), + }, + Err(err) => self.error_views.handle_widget(err, child_cx), + } + }; + + view.set(final_view); + }); + }); + + Ok((sycamore::prelude::view! { app_cx, (*view.get()) }, disposer)) + }; + } + // On the engine-side, this is impossible (we cannot be instructed to fetch) + #[cfg(not(target_arch = "wasm32"))] + None => unreachable!(), + } + } + + /// Gets the view and disposer for the given widget path. This will perform + /// asynchronous fetching as needed to fetch state from the server, and + /// will also handle engine-side state pass-through. This function will + /// propagate as many errors as it can, though those occurring inside a + /// `spawn_local_scoped` environment will be resolved to error views. + /// + /// This is intended for use with widgets that use unreactive state. See + /// `.get_widget_view()` for widgets that use reactive state. + pub(crate) fn get_unreactive_widget_view<'a, F, S, P: Clone + 'static>( + &'a self, + app_cx: Scope<'a>, + path: PathMaybeWithLocale, + #[allow(unused_variables)] caller_path: PathMaybeWithLocale, + #[cfg(target_arch = "wasm32")] capsule_name: String, + template_state: TemplateState, // Empty on the browser-side + props: P, + #[cfg(target_arch = "wasm32")] preload_info: PreloadInfo, + view_fn: F, + #[cfg(target_arch = "wasm32")] fallback_fn: &Arc<dyn Fn(Scope, P) -> View<G> + Send + Sync>, + ) -> Result<(View<G>, ScopeDisposer<'a>), ClientError> + where + F: Fn(Scope, S, P) -> View<G> + Send + Sync + 'static, + S: MakeRx + Serialize + DeserializeOwned + UnreactiveState + 'static, + <S as MakeRx>::Rx: AnyFreeze + Clone + MakeUnrx<Unrx = S>, + { + match self.get_widget_state_no_fetch::<S>(&path, template_state)? { + Some(intermediate_state) => { + let mut view = View::empty(); + let disposer = create_child_scope(app_cx, |child_cx| { + // We go back from the unreactive state type wrapper to the base type (since + // it's unreactive) + view = view_fn(child_cx, intermediate_state.make_unrx(), props); + }); + Ok((view, disposer)) + } + // We need to asynchronously fetch the state from the server, which doesn't work + // ergonomically with the rest of the code, so we just break out entirely + #[cfg(target_arch = "wasm32")] + None => { + return { + let view = create_signal(app_cx, View::empty()); + + let fallback_fn = fallback_fn.clone(); + let disposer = create_child_scope(app_cx, |child_cx| { + // We'll render the fallback view in the meantime (which `PerseusApp` + // guarantees to be defined for capsules) + view.set((fallback_fn)(child_cx, props.clone())); + // Note: this uses `child_cx`, meaning the fetch will be aborted if the user + // goes to another page (when this page is cleaned + // up, including all child scopes) + let capsule_name = capsule_name.clone(); + spawn_local_scoped(child_cx, async move { + // Any errors that occur in here will be converted into proper error + // views using the reactor (it's not the + // nicest handling pattern, but in a future + // like this, it's the best we can do) + let final_view = { + let path_without_locale = + PathWithoutLocale(match preload_info.locale.as_str() { + "xx-XX" => path.to_string(), + locale => path + .strip_prefix(&format!("{}/", locale)) + .unwrap() + .to_string(), + }); + // We can simply use the preload system to perform the fetching + match self + .state_store + .preload( + &path_without_locale, + &preload_info.locale, + &capsule_name, + preload_info.was_incremental_match, + false, // Don't use the route preloading system + true, // This is a widget + ) + .await + { + // If that succeeded, we can use the same logic as before, and + // we know it can't return `Ok(None)` + // this time! We're in the browser, so we can just use an empty + // template state, rather than + // cloning the one we've been given (which is empty anyway). + Ok(()) => match self.get_widget_state_no_fetch::<S>( + &path, + TemplateState::empty(), + ) { + Ok(Some(intermediate_state)) => { + // Declare the relationship between the widget and its + // caller + self.state_store + .declare_dependency(&path, &caller_path); + + view_fn(child_cx, intermediate_state.make_unrx(), props) + } + Ok(None) => unreachable!(), + Err(err) => self.error_views.handle_widget(err, child_cx), + }, + Err(err) => self.error_views.handle_widget(err, child_cx), + } + }; + + view.set(final_view); + }); + }); + + Ok((sycamore::prelude::view! { app_cx, (*view.get()) }, disposer)) + }; + } + // On the engine-side, this is impossible (we cannot be instructed to fetch) + #[cfg(not(target_arch = "wasm32"))] + None => unreachable!(), + } + } + + /// Gets the state for the given widget. This will return `Ok(None)`, if the + /// state needs to be fetched from the server. + /// + /// This will check against the active and frozen states, but it will + /// extract state from the preload system on an initial load (as this is + /// how widget states are loaded in). Note that this also acts as a + /// general interface with the preload system for widgets, the role + /// of which is fulfilled for pages by the subsequent load system. + /// + /// On the engine-side, this will use the given template state (which will + /// be passed through, unlike on the browser-side, where it will always + /// be empty). + pub(crate) fn get_widget_state_no_fetch<S>( + &self, + url: &PathMaybeWithLocale, + server_state: TemplateState, + ) -> Result<Option<S::Rx>, ClientError> + where + S: MakeRx + Serialize + DeserializeOwned + 'static, + S::Rx: MakeUnrx<Unrx = S> + AnyFreeze + Clone, + { + if let Some(held_state) = self.get_held_state::<S>(url, true)? { + Ok(Some(held_state)) + } else if cfg!(target_arch = "wasm32") { + // On the browser-side, the given server state is empty, and we need to check + // the preload + match self.state_store.contains(url) { + // This implies either user preloading, or initial load automatic preloading + // from `__PERSEUS_INITIAL_WIDGET_STATES` + PssContains::Preloaded => { + let page_data = self.state_store.get_preloaded(url).unwrap(); + // Register an empty head + self.state_store.add_head(url, String::new(), true); + // And reactivize the state for registration + let typed_state = TemplateState::from_value(page_data.state) + .change_type::<Result<S, ServerErrorData>>(); + // This attempts a deserialization from a `Value`, which could fail + let unrx_res = typed_state + .into_concrete() + .map_err(|err| ClientInvariantError::InvalidState { source: err })?; + match unrx_res { + Ok(unrx) => { + let rx = unrx.make_rx(); + // Add that to the state store as the new active state + self.state_store.add_state(url, rx.clone(), false)?; + + Ok(Some(rx)) + } + // This would occur if there were an error in the widget that were + // transmitted to us + Err(ServerErrorData { status, msg }) => Err(ClientError::ServerError { + status, + message: msg, + }), + } + } + // We need to fetch the state from the server, which will require + // asynchronicity, so bail out of this function, which is + // not equipped for that + PssContains::None => Ok(None), + // Widgets have no heads, and must always be registered with a state + PssContains::Head | PssContains::HeadNoState => { + Err(ClientInvariantError::InvalidWidgetPssEntry.into()) + } + // These would have been caught by `get_held_state()` above + PssContains::All | PssContains::State => unreachable!(), + } + } + // On the engine-side, the given server state is correct, and `get_held_state()` + // will definitionally return `Ok(None)` + else if server_state.is_empty() { + // This would be quite concerning... + Err(ClientInvariantError::NoState.into()) + } else { + // Fall back to the state we were given, first + // giving it a type (this just sets a phantom type parameter) + let typed_state = server_state.change_type::<S>(); + // This attempts a deserialization from a `Value`, which could fail + let unrx = typed_state + .into_concrete() + .map_err(|err| ClientInvariantError::InvalidState { source: err })?; + let rx = unrx.make_rx(); + // Add that to the state store as the new active state + self.state_store.add_state(url, rx.clone(), false)?; + + Ok(Some(rx)) + } + } +} diff --git a/packages/perseus/src/router/app_route.rs b/packages/perseus/src/router/app_route.rs index 905414600b..66194f1070 100644 --- a/packages/perseus/src/router/app_route.rs +++ b/packages/perseus/src/router/app_route.rs @@ -1,5 +1,5 @@ use super::{match_route, RouteVerdict}; -use crate::template::{RenderCtx, TemplateNodeType}; +use crate::{reactor::Reactor, template::BrowserNodeType}; use sycamore::prelude::Scope; use sycamore_router::Route; @@ -9,7 +9,7 @@ pub(crate) struct PerseusRoute<'cx> { /// The current route verdict. The initialization value of this is /// completely irrelevant (it will be overridden immediately by the internal /// routing logic). - pub verdict: RouteVerdict<TemplateNodeType>, + pub verdict: RouteVerdict, /// The Sycamore scope that allows us to access the render context. /// /// This will *always* be `Some(_)` in actual applications. @@ -22,7 +22,9 @@ pub(crate) struct PerseusRoute<'cx> { impl<'cx> Default for PerseusRoute<'cx> { fn default() -> Self { Self { - verdict: RouteVerdict::NotFound, + verdict: RouteVerdict::NotFound { + locale: "xx-XX".to_string(), + }, // Again, this will never be accessed cx: None, } @@ -30,7 +32,7 @@ impl<'cx> Default for PerseusRoute<'cx> { } impl<'cx> PerseusRoute<'cx> { /// Gets the current route verdict. - pub fn get_verdict(&self) -> &RouteVerdict<TemplateNodeType> { + pub fn get_verdict(&self) -> &RouteVerdict { &self.verdict } } @@ -48,12 +50,12 @@ impl<'cx> Route for PerseusRoute<'cx> { .filter(|s| !s.is_empty()) .collect::<Vec<&str>>(); // This parsing is identical to the Sycamore router's - let render_ctx = RenderCtx::from_ctx(self.cx.unwrap()); // We know the scope will always exist + let reactor = Reactor::<BrowserNodeType>::from_cx(self.cx.unwrap()); // We know the scope will always exist let verdict = match_route( &path_segments, - &render_ctx.render_cfg, - &render_ctx.templates, - &render_ctx.locales, + &reactor.render_cfg, + &reactor.entities, + &reactor.locales, ); Self { verdict, diff --git a/packages/perseus/src/router/get_initial_view.rs b/packages/perseus/src/router/get_initial_view.rs deleted file mode 100644 index 9b50314ead..0000000000 --- a/packages/perseus/src/router/get_initial_view.rs +++ /dev/null @@ -1,376 +0,0 @@ -use crate::error_pages::ErrorPageData; -use crate::errors::*; -use crate::i18n::detect_locale; -use crate::router::{match_route, RouteManager}; -use crate::router::{RouteInfo, RouteVerdict, RouterLoadState}; -use crate::template::{RenderCtx, TemplateNodeType, TemplateState}; -use crate::utils::checkpoint; -use fmterr::fmt_err; -use sycamore::prelude::*; -use sycamore::rt::Reflect; // We can piggyback off Sycamore to avoid bringing in `js_sys` -use wasm_bindgen::{JsCast, JsValue}; -use web_sys::Element; - -/// Gets the initial view that we should display when the app first loads. This -/// doesn't need to be asynchronous, since initial loads provide everything -/// necessary for hydration in one single HTML file (including state and -/// translator sources). -/// -/// Note that this function can only be run once, since it will delete the -/// initial state infrastructure from the page entirely. If this function is run -/// without that infrastructure being present, an error page will be rendered. -/// -/// Note that this will automatically update the router state just before it -/// returns, meaning that any errors that may occur after this function has been -/// called need to reset the router state to be an error. -pub(crate) fn get_initial_view<'a>( - cx: Scope<'a>, - path: String, // The full path, not the template path (but parsed a little) - route_manager: &'a RouteManager<'a, TemplateNodeType>, -) -> InitialView { - let render_ctx = RenderCtx::from_ctx(cx); - let router_state = &render_ctx.router; - let translations_manager = &render_ctx.translations_manager; - let locales = &render_ctx.locales; - let templates = &render_ctx.templates; - let render_cfg = &render_ctx.render_cfg; - let error_pages = &render_ctx.error_pages; - let pss = &render_ctx.page_state_store; - - let path = js_sys::decode_uri_component(&path) - .unwrap() - .as_string() - .unwrap(); - // Start by figuring out what template we should be rendering - let path_segments = path - .split('/') - .filter(|s| !s.is_empty()) - .collect::<Vec<&str>>(); // This parsing is identical to the Sycamore router's - let verdict = match_route(&path_segments, &render_cfg, &templates, &locales); - match &verdict { - RouteVerdict::Found(RouteInfo { - path, - template, - locale, - // Since we're not requesting anything from the server, we don't need to worry about - // whether it's an incremental match or not - was_incremental_match: _, - }) => { - let path_with_locale = match locale.as_str() { - "xx-XX" => path.clone(), - locale => format!("{}/{}", locale, &path), - }; - // Update the router state - router_state.set_load_state(RouterLoadState::Loading { - template_name: template.get_path(), - path: path_with_locale.clone(), - }); - router_state.set_last_verdict(verdict.clone()); - - // Get the initial state and decide what to do from that - let initial_state = get_initial_state(); - match initial_state { - InitialState::Present(state) => { - checkpoint("initial_state_present"); - // Unset the initial state variable so we perform subsequent renders correctly - // This monstrosity is needed until `web-sys` adds a `.set()` method on `Window` - // We don't do this for the global state because it should hang around - // uninitialized until a template wants it (if we remove it before then, we're - // stuffed) - Reflect::set( - &JsValue::from(web_sys::window().unwrap()), - &JsValue::from("__PERSEUS_INITIAL_STATE"), - &JsValue::undefined(), - ) - .unwrap(); - - // Get the translator from the page (this has to exist, or the server stuffed - // up); doing this without a network request minimizes - // the time to interactivity (improving UX drastically), while meaning that we - // never have to fetch translations separately unless the user switches locales - let translations_str = match get_translations() { - Some(translations_str) => translations_str, - None => { - router_state.set_load_state(RouterLoadState::ErrorLoaded { - path: path_with_locale.clone(), - }); - return InitialView::Error(error_pages.get_view_and_render_head( - cx, - "*", - 500, - "expected translations in global variable, but none found", - None, - )); - } - }; - let translator = translations_manager - .get_translator_for_translations_str(&locale, &translations_str); - let translator = match translator { - Ok(translator) => translator, - Err(err) => { - router_state.set_load_state(RouterLoadState::ErrorLoaded { - path: path_with_locale.clone(), - }); - return InitialView::Error(match &err { - // These errors happen because we couldn't get a translator, so they - // certainly don't get one - ClientError::FetchError(FetchError::NotOk { - url, status, .. - }) => error_pages.get_view_and_render_head( - cx, - url, - *status, - &fmt_err(&err), - None, - ), - ClientError::FetchError(FetchError::SerFailed { url, .. }) => { - error_pages.get_view_and_render_head( - cx, - url, - 500, - &fmt_err(&err), - None, - ) - } - ClientError::LocaleNotSupported { .. } => error_pages - .get_view_and_render_head( - cx, - &format!("/{}/...", locale), - 404, - &fmt_err(&err), - None, - ), - // No other errors should be returned, but we'll give any a 400 - _ => error_pages.get_view_and_render_head( - cx, - &format!("/{}/...", locale), - 400, - &fmt_err(&err), - None, - ), - }); - } - }; - - #[cfg(feature = "cache-initial-load")] - { - // Cache the page's head in the PSS (getting it as reliably as we can) - let head_str = get_head(); - pss.add_head(&path, head_str); - } - - let path = template.get_path(); - // Pre-emptively declare the page interactive since all we do from this point - // is hydrate - checkpoint("page_interactive"); - // Update the router state - router_state.set_load_state(RouterLoadState::Loaded { - template_name: path, - path: path_with_locale.clone(), - }); - // Render the actual template to the root (done imperatively due to child - // scopes) - template.render_for_template_client( - path_with_locale, - state, - cx, - route_manager, - translator, - ); - - InitialView::Success - } - // We have an error that the server sent down, so we should just return that error - // view - InitialState::Error(ErrorPageData { url, status, err }) => { - checkpoint("initial_state_error"); - router_state.set_load_state(RouterLoadState::ErrorLoaded { - path: path_with_locale.clone(), - }); - // We don't need to replace the head, because the server's handled that for us - InitialView::Error(error_pages.get_view(cx, &url, status, &err, None)) - } - // The entire purpose of this function is to work with the initial state, so if this - // is true, then we have a problem - // Theoretically, this should never - // happen... (but I've seen magical infinite loops that crash browsers, so I'm - // hedging my bets) - InitialState::NotPresent => { - checkpoint("initial_state_error"); - router_state.set_load_state(RouterLoadState::ErrorLoaded { - path: path_with_locale.clone(), - }); - InitialView::Error(error_pages.get_view_and_render_head(cx, "*", 400, "expected initial state render, found subsequent load (highly likely to be a core perseus bug)", None)) - } - } - } - // If the user is using i18n, then they'll want to detect the locale on any paths - // missing a locale Those all go to the same system that redirects to the - // appropriate locale Note that `container` doesn't exist for this scenario - RouteVerdict::LocaleDetection(path) => { - InitialView::Redirect(detect_locale(path.clone(), &locales)) - } - RouteVerdict::NotFound => InitialView::Error({ - checkpoint("not_found"); - if let InitialState::Error(ErrorPageData { url, status, err }) = get_initial_state() { - router_state.set_load_state(RouterLoadState::ErrorLoaded { path: url.clone() }); - // If this is an error from an initial state page, then we'll hydrate whatever's - // already there - // - // Since this page has come from the server, anything could have happened, so we - // provide no translator (and one certainly won't exist in context) - // But we don't need to replace the head, since the server will have already - // done that - error_pages.get_view(cx, &url, status, &err, None) - } else { - // TODO Update the router state - // router_state.set_load_state(RouterLoadState::ErrorLoaded { - // path: path_with_locale.clone() - // }); - // Given that were only handling the initial load, this should really never - // happen... - error_pages.get_view_and_render_head(cx, "", 404, "not found", None) - } - }), - } -} - -/// A representation of the possible outcomes of getting the view for the -/// initial load. -pub(crate) enum InitialView { - /// The page has been successfully rendered and sent through the given - /// signal. - /// - /// Due to the use of child scopes, we can't just return a actual view, - /// this has to be done imperatively. - Success, - /// An error view has been produced, which should replace the current view. - Error(View<TemplateNodeType>), - /// We need to redirect somewhere else, and the path to redirect to is - /// attached. - /// - /// Currently, this is only used by locale redirection, though this could - /// theoretically also be used for server-level reloads, if those - /// directives are ever supported. - Redirect(String), -} - -/// A representation of whether or not the initial state was present. If it was, -/// it could be `None` (some templates take no state), and if not, then this -/// isn't an initial load, and we need to request the page from the server. It -/// could also be an error that the server has rendered. -#[derive(Debug)] -enum InitialState { - /// A non-error initial state has been injected. This could be empty, since - /// not all pages have state. - Present(TemplateState), - /// An initial state has been injected that indicates an error. - Error(ErrorPageData), - /// No initial state has been injected (or if it has, it's been deliberately - /// unset). - NotPresent, -} - -/// Gets the initial state injected by the server, if there was any. This is -/// used to differentiate initial loads from subsequent ones, which have -/// different log chains to prevent double-trips (a common SPA problem). -fn get_initial_state() -> InitialState { - let val_opt = web_sys::window().unwrap().get("__PERSEUS_INITIAL_STATE"); - let js_obj = match val_opt { - Some(js_obj) => js_obj, - None => return InitialState::NotPresent, - }; - // The object should only actually contain the string value that was injected - let state_str = match js_obj.as_string() { - Some(state_str) => state_str, - None => return InitialState::NotPresent, - }; - if state_str.starts_with("error-") { - // We strip the prefix and escape any tab/newline control characters (inserted - // by `fmterr`) Any others are user-inserted, and this is documented - let err_page_data_str = state_str - .strip_prefix("error-") - .unwrap() - .replace('\n', "\\n") - .replace('\t', "\\t"); - // There will be error page data encoded after `error-` - let err_page_data = match serde_json::from_str::<ErrorPageData>(&err_page_data_str) { - Ok(render_cfg) => render_cfg, - // If there's a serialization error, we'll create a whole new error (500) - Err(err) => ErrorPageData { - url: "[current]".to_string(), - status: 500, - err: format!("couldn't serialize error from server: '{}'", err), - }, - }; - InitialState::Error(err_page_data) - } else { - match TemplateState::from_str(&state_str) { - Ok(state) => InitialState::Present(state), - // An actual error means the state was provided, but it was malformed, so we'll render - // an error page - Err(err) => InitialState::Error(ErrorPageData { - url: "[current]".to_string(), - status: 500, - err: format!("couldn't serialize page data from server: '{}'", err), - }), - } - } -} - -/// Gets the translations injected by the server, if there was any. If there are -/// errors in this, we can return `None` and not worry about it, they'll be -/// handled by the initial state. -fn get_translations() -> Option<String> { - let val_opt = web_sys::window().unwrap().get("__PERSEUS_TRANSLATIONS"); - let js_obj = match val_opt { - Some(js_obj) => js_obj, - None => return None, - }; - // The object should only actually contain the string value that was injected - let state_str = match js_obj.as_string() { - Some(state_str) => state_str, - None => return None, - }; - - // With translations, there's no such thing as `None` (even apps without i18n - // have a dummy translator) - Some(state_str) -} - -/// Gets the entire contents of the current `<head>`, up to the Perseus head-end -/// comment (which prevents any JS that was loaded later from being included). -/// This is not guaranteed to always get exactly the original head, but it's -/// pretty good, and prevents unnecessary network requests, while enabling the -/// caching of initially loaded pages. -#[cfg(feature = "cache-initial-load")] -fn get_head() -> String { - let document = web_sys::window().unwrap().document().unwrap(); - // Get the current head - // The server sends through a head, so we can guarantee that one is present (and - // it's mandated for custom initial views) - let head_node = document.query_selector("head").unwrap().unwrap(); - // Get all the elements after the head boundary (otherwise we'd be duplicating - // the initial stuff) - let els = head_node - .query_selector_all(r#"meta[itemprop='__perseus_head_boundary'] ~ *"#) - .unwrap(); - // No, `NodeList` does not have an iterator implementation... - let mut head_vec = Vec::new(); - for i in 0..els.length() { - let elem: Element = els.get(i).unwrap().unchecked_into(); - // Check if this is the delimiter that denotes the end of the head (it's - // impossible for the user to add anything below here), since we don't - // want to get anything that other scripts might have added (but if that shows - // up, it shouldn't be catastrophic) - if elem.tag_name().to_lowercase() == "meta" - && elem.get_attribute("itemprop") == Some("__perseus_head_end".to_string()) - { - break; - } - let html = elem.outer_html(); - head_vec.push(html); - } - - head_vec.join("\n") -} diff --git a/packages/perseus/src/router/get_subsequent_view.rs b/packages/perseus/src/router/get_subsequent_view.rs deleted file mode 100644 index d9b8e515c8..0000000000 --- a/packages/perseus/src/router/get_subsequent_view.rs +++ /dev/null @@ -1,279 +0,0 @@ -use crate::errors::*; -use crate::page_data::PageDataPartial; -use crate::router::{RouteManager, RouteVerdict, RouterLoadState}; -use crate::state::PssContains; -use crate::template::{RenderCtx, Template, TemplateNodeType, TemplateState}; -use crate::utils::checkpoint; -use crate::utils::fetch; -use crate::utils::get_path_prefix_client; -use crate::utils::replace_head; -use fmterr::fmt_err; -use serde_json::Value; -use std::rc::Rc; -use sycamore::prelude::*; - -pub(crate) struct GetSubsequentViewProps<'a> { - /// The app's reactive scope. - pub cx: Scope<'a>, - /// The app's route manager. - pub route_manager: &'a RouteManager<'a, TemplateNodeType>, - /// The path we're rendering for (not the template path, the full path, - /// though parsed a little). - pub path: String, - /// The template to render for. - pub template: Rc<Template<TemplateNodeType>>, - /// Whether or not the router returned an incremental match (if this page - /// exists on a template using incremental generation and it wasn't defined - /// at build time). - pub was_incremental_match: bool, - /// The locale we're rendering in. - pub locale: String, - /// The current route verdict. This will be stored in context so that it can - /// be used for possible reloads. Eventually, this will be made obsolete - /// when Sycamore supports this natively. - pub route_verdict: RouteVerdict<TemplateNodeType>, -} - -/// Gets the view to render on a change of route after the app has already -/// loaded. This involves network requests to determine the state of the page, -/// which is then used to render directly. We don't need to request the HTML, -/// since that would just take longer, and we have everything we need to render -/// it. We also won't be hydrating anything, so there's no point in getting the -/// HTML, it actually slows page transitions down. -/// -/// Note that this will automatically update the router state just before it -/// returns, meaning that any errors that may occur after this function has been -/// called need to reset the router state to be an error. -pub(crate) async fn get_subsequent_view( - GetSubsequentViewProps { - cx, - route_manager, - path, - template, - was_incremental_match, - locale, - route_verdict, - }: GetSubsequentViewProps<'_>, -) -> SubsequentView { - let render_ctx = RenderCtx::from_ctx(cx); - let router_state = &render_ctx.router; - let translations_manager = &render_ctx.translations_manager; - let error_pages = &render_ctx.error_pages; - let pss = &render_ctx.page_state_store; - - let path_with_locale = match locale.as_str() { - "xx-XX" => path.clone(), - locale => format!("{}/{}", locale, &path), - }; - // Update the router state - router_state.set_load_state(RouterLoadState::Loading { - template_name: template.get_path(), - path: path_with_locale.clone(), - }); - router_state.set_last_verdict(route_verdict.clone()); - - checkpoint("initial_state_not_present"); - // Before we fetch anything, first check if there's an entry in the PSS already - // (if there is, we can avoid a network request) - let page_data: Result<PageDataPartial, View<TemplateNodeType>> = match pss.contains(&path) { - // We only have one part of the puzzle (or nothing at all), and no guarantee that the other - // doesn't exist, so we'll have to check with the server to be safe - PssContains::State | PssContains::Head | PssContains::None => { - // // If we're getting data about the index page, explicitly set it to that - // // This can be handled by the Perseus server (and is), but not by static - // // exporting - // let path_norm = match path.is_empty() { - // true => "index".to_string(), - // false => path.to_string(), - // }; - let path_norm = path.to_string(); - // Get the static page data (head and state) - let asset_url = format!( - "{}/.perseus/page/{}/{}.json?template_name={}&was_incremental_match={}", - get_path_prefix_client(), - locale, - path_norm, - template.get_path(), - was_incremental_match - ); - // If this doesn't exist, then it's a 404 (we went here by explicit navigation, - // but it may be an unservable ISR page or the like) - let page_data_str = fetch(&asset_url).await; - match &page_data_str { - Ok(page_data_str_opt) => match page_data_str_opt { - Some(page_data_str) => { - // All good, deserialize the page data - let page_data = serde_json::from_str::<PageDataPartial>(&page_data_str); - match page_data { - Ok(page_data) => { - // Add the head to the PSS for future use (we make absolutely no - // assumptions about state and leave that to the macros) - pss.add_head(&path, page_data.head.to_string()); - Ok(page_data) - } - // If the page failed to serialize, it's a server error - Err(err) => { - router_state.set_load_state(RouterLoadState::ErrorLoaded { - path: path_with_locale.clone(), - }); - Err(error_pages.get_view_and_render_head( - cx, - &asset_url, - 500, - &fmt_err(&err), - None, - )) - } - } - } - // No translators ready yet - None => { - router_state.set_load_state(RouterLoadState::ErrorLoaded { - path: path_with_locale.clone(), - }); - Err(error_pages.get_view_and_render_head( - cx, - &asset_url, - 404, - "page not found", - None, - )) - } - }, - Err(err) => { - router_state.set_load_state(RouterLoadState::ErrorLoaded { - path: path_with_locale.clone(), - }); - match &err { - // No translators ready yet - ClientError::FetchError(FetchError::NotOk { url, status, .. }) => { - Err(error_pages.get_view_and_render_head( - cx, - url, - *status, - &fmt_err(&err), - None, - )) - } - // No other errors should be returned, but we'll give any a 400 - _ => Err(error_pages.get_view_and_render_head( - cx, - &asset_url, - 400, - &fmt_err(&err), - None, - )), - } - } - } - } - // We have everything locally, so we can move right ahead! - PssContains::All => Ok(PageDataPartial { - state: Value::Null, /* The macros will preferentially use the PSS state, - * so this will never be parsed */ - head: pss.get_head(&path).unwrap(), - }), - // We only have document metadata, but the page definitely takes no state, so we're fine - PssContains::HeadNoState => Ok(PageDataPartial { - state: Value::Null, - head: pss.get_head(&path).unwrap(), - }), - // The page's data has been preloaded at some other time - PssContains::Preloaded => { - let page_data = pss.get_preloaded(&path).unwrap(); - // Register the head, otherwise it will never be registered and the page will - // never properly show up in the PSS (meaning future preload - // calls will go through, creating unnecessary network requests) - pss.add_head(&path, page_data.head.to_string()); - Ok(page_data) - } - }; - // Any errors will be prepared error pages ready for return - let page_data = match page_data { - Ok(page_data) => page_data, - Err(view) => return SubsequentView::Error(view), - }; - - // Interpolate the metadata directly into the document's `<head>` - replace_head(&page_data.head); - - // Now get the translator (this will be cached if the user hasn't switched - // locales) - let translator = translations_manager - .get_translator_for_locale(&locale) - .await; - let translator = match translator { - Ok(translator) => translator, - Err(err) => { - router_state.set_load_state(RouterLoadState::ErrorLoaded { - path: path_with_locale.clone(), - }); - match &err { - // These errors happen because we couldn't get a translator, so they certainly don't - // get one - ClientError::FetchError(FetchError::NotOk { url, status, .. }) => { - return SubsequentView::Error(error_pages.get_view_and_render_head( - cx, - url, - *status, - &fmt_err(&err), - None, - )) - } - ClientError::FetchError(FetchError::SerFailed { url, .. }) => { - return SubsequentView::Error(error_pages.get_view_and_render_head( - cx, - url, - 500, - &fmt_err(&err), - None, - )) - } - ClientError::LocaleNotSupported { locale } => { - return SubsequentView::Error(error_pages.get_view_and_render_head( - cx, - &format!("/{}/...", locale), - 404, - &fmt_err(&err), - None, - )) - } - // No other errors should be returned, but we'll give any a 400 - _ => { - return SubsequentView::Error(error_pages.get_view_and_render_head( - cx, - &format!("/{}/...", locale), - 400, - &fmt_err(&err), - None, - )) - } - } - } - }; - - let template_name = template.get_path(); - // Pre-emptively update the router state - checkpoint("page_interactive"); - // Update the router state - router_state.set_load_state(RouterLoadState::Loaded { - template_name, - path: path_with_locale.clone(), - }); - // Now return the view that should be rendered - template.render_for_template_client( - path_with_locale, - TemplateState::from_value(page_data.state), - cx, - route_manager, - translator, - ); - SubsequentView::Success -} - -pub(crate) enum SubsequentView { - /// The page view *has been* rendered *and* displayed. - Success, - /// An error view was rendered, and shoudl now be displayed. - Error(View<TemplateNodeType>), -} diff --git a/packages/perseus/src/router/match_route.rs b/packages/perseus/src/router/match_route.rs index 2ea1b5bd52..e2c199576a 100644 --- a/packages/perseus/src/router/match_route.rs +++ b/packages/perseus/src/router/match_route.rs @@ -1,57 +1,9 @@ -use super::{RouteInfo, RouteInfoAtomic, RouteVerdict, RouteVerdictAtomic}; +use super::{RouteInfo, RouteVerdict}; use crate::i18n::Locales; -use crate::template::{ArcTemplateMap, Template, TemplateMap}; -use crate::Html; +use crate::path::*; +use crate::template::{Entity, EntityMap, Forever}; use std::collections::HashMap; -use std::rc::Rc; - -/// 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 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 = None; - // We'll try a direct match first - if let Some(template_root_path) = $render_cfg.get(path) { - template_name = Some(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_none() { - // 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 (maximize specificity) - if let Some(template_root_path) = $render_cfg.get(&path_to_try) { - was_incremental_match = true; - template_name = Some(template_root_path.to_string()); - } else { - break; - } - } - } - // If we still have nothing, then the page doesn't exist - if template_name.is_none() { - 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.unwrap(), was_incremental_match) - }}; -} +use sycamore::web::Html; /// 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 @@ -65,47 +17,42 @@ macro_rules! get_template_for_path { /// 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 behavior)! -/// -/// *Note:* in the vast majority of cases, you should never need to use -/// this function. -pub fn get_template_for_path<G: Html>( - raw_path: &str, - render_cfg: &HashMap<String, String>, - templates: &TemplateMap<G>, -) -> (Option<Rc<Template<G>>>, bool) { - let (template_name, was_incremental_match) = - get_template_for_path!(raw_path, render_cfg, templates); - - ( - templates.get(&template_name).cloned(), - was_incremental_match, - ) -} - -/// A version of `get_template_for_path` that accepts an `ArcTemplateMap<G>`. -/// This is used by `match_route_atomic`, which should be used in scenarios in -/// which the template map needs to be passed between threads. -/// -/// Warning: this returns a `&Template<G>` rather than a `Rc<Template<G>>`, and -/// thus should only be used independently of the rest of Perseus (through -/// `match_route_atomic`). -/// -/// *Note:* in the vast majority of cases, you should never need to use -/// this function. -pub fn get_template_for_path_atomic<'a, G: Html>( - raw_path: &str, +fn get_template_for_path<'a, G: Html>( + path: &str, render_cfg: &HashMap<String, String>, - templates: &'a ArcTemplateMap<G>, -) -> (Option<&'a Template<G>>, bool) { - let (template_name, was_incremental_match) = - get_template_for_path!(raw_path, render_cfg, templates); + entities: &'a EntityMap<G>, +) -> (Option<&'a Forever<Entity<G>>>, bool) { + let mut was_incremental_match = false; + // Match the path to one of the entities + let mut entity_name = None; + // We'll try a direct match first + if let Some(entity_root_path) = render_cfg.get(path) { + entity_name = Some(entity_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 entity_name.is_none() { + // 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("/") + "/*"; - ( - templates - .get(&template_name) - .map(|pointer| pointer.as_ref()), - was_incremental_match, - ) + // If we find something, keep going until we don't (maximize specificity) + if let Some(entity_root_path) = render_cfg.get(&path_to_try) { + was_incremental_match = true; + entity_name = Some(entity_root_path.to_string()); + } + } + } + // If we still have nothing, then the page doesn't exist + if let Some(entity_name) = entity_name { + (entities.get(&entity_name), was_incremental_match) + } else { + (None, was_incremental_match) + } } /// Matches the given path to a `RouteVerdict`. This takes a `TemplateMap` to @@ -113,20 +60,16 @@ pub fn get_template_for_path_atomic<'a, G: Html>( /// i18n is being used. The path this takes should be raw, it may or may not /// have a locale, but should be split into segments by `/`, with empty ones /// having been removed. -/// -/// *Note:* in the vast majority of cases, you should never need to use -/// this function. -pub fn match_route<G: Html>( +pub(crate) fn match_route<G: Html>( path_slice: &[&str], render_cfg: &HashMap<String, String>, - templates: &TemplateMap<G>, + entities: &EntityMap<G>, locales: &Locales, -) -> RouteVerdict<G> { +) -> RouteVerdict { let path_vec = 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 path_joined = PathMaybeWithLocale(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]; @@ -135,116 +78,57 @@ pub fn match_route<G: Html>( 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("/"); + let path_without_locale = PathWithoutLocale(path_slice[1..].to_vec().join("/")); // Get the template to use - let (template, was_incremental_match) = - get_template_for_path(&path_without_locale, render_cfg, templates); - verdict = match template { - Some(template) => RouteVerdict::Found(RouteInfo { + let (entity, was_incremental_match) = + get_template_for_path(&path_without_locale, render_cfg, entities); + match entity { + Some(entity) => RouteVerdict::Found(RouteInfo { locale: locale.to_string(), // This will be used in asset fetching from the server path: path_without_locale, - template, + // The user can get the full entity again if they want to, we just use it to + // make sure the path exists + entity_name: entity.get_path(), was_incremental_match, }), - None => RouteVerdict::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 = RouteVerdict::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 = RouteVerdict::LocaleDetection(path_joined); - } else { - // Get the template to use - let (template, was_incremental_match) = - get_template_for_path(&path_joined, render_cfg, templates); - verdict = match template { - Some(template) => RouteVerdict::Found(RouteInfo { - locale: locales.default.to_string(), - // This will be used in asset fetching from the server - path: path_joined, - template, - was_incremental_match, - }), - None => RouteVerdict::NotFound, - }; - } - - verdict -} - -/// A version of `match_route` that accepts an `ArcTemplateMap<G>`. This should -/// be used in multithreaded situations, like on the server. -/// -/// *Note:* in the vast majority of cases, you should never need to use -/// this function. -pub fn match_route_atomic<'a, G: Html>( - path_slice: &[&str], - render_cfg: &HashMap<String, String>, - templates: &'a ArcTemplateMap<G>, - 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 { + None => RouteVerdict::NotFound { 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) + let path_joined = PathWithoutLocale(path_joined.0); + RouteVerdict::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); + let path_joined = PathWithoutLocale(path_joined.0); + RouteVerdict::LocaleDetection(path_joined) } else { + // We're not using i18n + let path_joined = PathWithoutLocale(path_joined.0); // 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 { + let (entity, was_incremental_match) = + get_template_for_path(&path_joined, render_cfg, entities); + match entity { + Some(entity) => RouteVerdict::Found(RouteInfo { locale: locales.default.to_string(), // This will be used in asset fetching from the server path: path_joined, - template, + // The user can get the full entity again if they want to, we just use it to make + // sure the path exists + entity_name: entity.get_path(), was_incremental_match, }), - None => RouteVerdictAtomic::NotFound, - }; + None => RouteVerdict::NotFound { + locale: "xx-XX".to_string(), + }, + } } - - verdict } diff --git a/packages/perseus/src/router/mod.rs b/packages/perseus/src/router/mod.rs index f56525e77a..989b8dd3ae 100644 --- a/packages/perseus/src/router/mod.rs +++ b/packages/perseus/src/router/mod.rs @@ -1,31 +1,18 @@ #[cfg(target_arch = "wasm32")] mod app_route; -#[cfg(target_arch = "wasm32")] -mod get_initial_view; -#[cfg(target_arch = "wasm32")] -mod get_subsequent_view; mod match_route; +#[cfg(target_arch = "wasm32")] mod page_disposer; -mod route_manager; mod route_verdict; #[cfg(target_arch = "wasm32")] -mod router_component; mod router_state; #[cfg(target_arch = "wasm32")] pub(crate) use app_route::PerseusRoute; -pub use match_route::{ - get_template_for_path, get_template_for_path_atomic, match_route, match_route_atomic, -}; -pub use route_verdict::{RouteInfo, RouteInfoAtomic, RouteVerdict, RouteVerdictAtomic}; +pub(crate) use match_route::match_route; +pub use route_verdict::{FullRouteInfo, FullRouteVerdict, RouteInfo, RouteVerdict}; #[cfg(target_arch = "wasm32")] -pub(crate) use router_component::{perseus_router, PerseusRouterProps}; pub use router_state::{RouterLoadState, RouterState}; #[cfg(target_arch = "wasm32")] -pub(crate) use get_initial_view::{get_initial_view, InitialView}; -#[cfg(target_arch = "wasm32")] -pub(crate) use get_subsequent_view::{get_subsequent_view, GetSubsequentViewProps, SubsequentView}; - -pub use page_disposer::PageDisposer; -pub use route_manager::RouteManager; +pub(crate) use page_disposer::PageDisposer; diff --git a/packages/perseus/src/router/page_disposer.rs b/packages/perseus/src/router/page_disposer.rs index e939832f60..492c9d7693 100644 --- a/packages/perseus/src/router/page_disposer.rs +++ b/packages/perseus/src/router/page_disposer.rs @@ -1,9 +1,5 @@ use std::rc::Rc; - -use sycamore::{ - prelude::{create_signal, Scope, Signal}, - reactive::ScopeDisposer, -}; +use sycamore::{prelude::RcSignal, reactive::ScopeDisposer}; /// This stores the disposers for user pages so that they can be safely /// unmounted when the view changes. @@ -11,30 +7,24 @@ use sycamore::{ /// If you're using the `#[template]` macro and the like, you will never need to /// use this. If you're not using the macros for some reason, you shoudl consult /// their code to make sure you use this correctly. -#[derive(Clone, Copy)] -pub struct PageDisposer<'a> { +#[derive(Clone, Default)] +pub(crate) struct PageDisposer<'app> { /// The underlying `ScopeDisposer`. This will initially be `None` before any /// views have been rendered. /// /// There is no way to get this underlying scope disposer, it can only be /// set. Hence, we prevent there ever being multiple references to the /// underlying `Signal`. - disposer: &'a Signal<Option<ScopeDisposer<'a>>>, + disposer: RcSignal<Option<ScopeDisposer<'app>>>, } -impl<'a> PageDisposer<'a> { - /// Creates a new `PageDisposer` in the given app scope. - pub(crate) fn new(cx: Scope<'a>) -> Self { - Self { - disposer: create_signal(cx, None), - } - } +impl<'app> PageDisposer<'app> { /// Updates the undelrying data structure to hold the given disposer, taking /// any previous disposer and disposing it. /// /// # Safety /// This must not be called inside the scope in which the previous disposer /// was created. - pub fn update(&self, new_disposer: ScopeDisposer<'a>) { + pub(crate) unsafe fn update(&self, new_disposer: ScopeDisposer<'app>) { // Dispose of any old disposers if self.disposer.get().is_some() { let old_disposer_rc = self.disposer.take(); @@ -42,8 +32,8 @@ impl<'a> PageDisposer<'a> { let old_disposer = old_disposer_option.unwrap(); // We're in a conditional that checked this // SAFETY: This function is documented to be only called when we're not inside - // the same scope as we're disposing of - unsafe { old_disposer.dispose() }; + // the same scope as we're disposing of. + old_disposer.dispose(); } self.disposer.set(Some(new_disposer)); diff --git a/packages/perseus/src/router/route_manager.rs b/packages/perseus/src/router/route_manager.rs deleted file mode 100644 index 9468327d6f..0000000000 --- a/packages/perseus/src/router/route_manager.rs +++ /dev/null @@ -1,48 +0,0 @@ -use super::PageDisposer; -use sycamore::{ - prelude::{create_signal, Scope, ScopeDisposer, Signal, View}, - web::Html, -}; - -/// The internals that allow Perseus to manage the many routes of an app, -/// including child scope disposal. This should almost never be interacted with -/// by end users! -/// -/// This takes the lifetime of the whole app's root scope. Note that this is not -/// put in `RenderCtx`, since it should not be accessible except through raw -/// templates. -/// -/// Note that this is used instead of the component parts to ensure lifetime -/// sameness. -#[derive(Clone)] -pub struct RouteManager<'cx, G: Html> { - page_disposer: PageDisposer<'cx>, - // We occasionally need to `.get()` and `.take()` this - pub(crate) view: &'cx Signal<View<G>>, -} -// We don't allow direct field access to minimize the likelihood to users -// shooting themselves in the foot (or, in this case, kidney) -impl<'cx, G: Html> RouteManager<'cx, G> { - /// Creates a new route manager, with an empty view and no scopes to dispose - /// of yet. - pub(crate) fn new(cx: Scope<'cx>) -> Self { - Self { - page_disposer: PageDisposer::new(cx), - view: create_signal(cx, View::empty()), - } - } - /// Updates the current view of the app. The argument here will be rendered - /// to the root of the app. - /// - /// This should NEVER be invoked outside the typical lifecycle of Perseus - /// routing! If you want to render and error page or the like, use that - /// API, not this one! - pub fn update_view(&self, new_view: View<G>) { - self.view.set(new_view); - } - /// Updates the underlying scope disposer. See the docs for [`PageDisposer`] - /// for more information. - pub fn update_disposer(&mut self, new_disposer: ScopeDisposer<'cx>) { - self.page_disposer.update(new_disposer); - } -} diff --git a/packages/perseus/src/router/route_verdict.rs b/packages/perseus/src/router/route_verdict.rs index a19413cfc9..cb04f5a3c5 100644 --- a/packages/perseus/src/router/route_verdict.rs +++ b/packages/perseus/src/router/route_verdict.rs @@ -1,17 +1,17 @@ -use crate::template::Template; -use crate::Html; -use std::rc::Rc; +use crate::path::PathWithoutLocale; +use crate::template::{Entity, EntityMap}; +use sycamore::web::Html; /// 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. -#[derive(Debug, Clone)] -pub struct RouteInfo<G: Html> { - /// The actual path of the route. - pub path: String, +#[derive(Clone, Debug)] +pub struct FullRouteInfo<'a, G: Html> { + /// The actual path of the route. This does *not* include the locale! + pub path: PathWithoutLocale, /// The template that will be used. The app shell will derive props and a /// translator to pass to the template function. - pub template: Rc<Template<G>>, + pub entity: &'a Entity<G>, /// 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 @@ -22,32 +22,40 @@ pub struct RouteInfo<G: Html> { } /// The possible outcomes of matching a route in an app. -#[derive(Debug, Clone)] -pub enum RouteVerdict<G: Html> { +#[derive(Clone, Debug)] +pub enum FullRouteVerdict<'a, G: Html> { /// The given route was found, and route information is attached. - Found(RouteInfo<G>), + Found(FullRouteInfo<'a, G>), /// The given route was not found, and a `404 Not Found` page should be - /// shown. - NotFound, + /// shown. In apps using i18n, an invalid page without a locale will + /// first be redirected, before being later resolved as 404. Hence, + /// we can always provide a locale here, allowing the error view to be + /// appropriately translated. (I.e. there will never be a non-localized + /// 404 page in Perseus.) + NotFound { + /// The active locale. + locale: String, + }, /// The given route maps to the locale detector, which will redirect the /// user to the attached path (in the appropriate locale). - LocaleDetection(String), + /// + /// The attached path will have the appropriate locale prepended during the + /// detection process. + LocaleDetection(PathWithoutLocale), } /// 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<Template<G>>`. That means this is not -/// compatible with Perseus on the client-side, only on the server-side. -#[derive(Debug)] -pub struct RouteInfoAtomic<'a, G: Html> { - /// 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<G>, +/// Unlike [`FullRouteInfo`], this does not store the actual template being +/// used, instead it only stores its name, making it much easier to store. +#[derive(Clone, Debug)] +pub struct RouteInfo { + /// The actual path of the route. This does *not* include the locale! + pub path: PathWithoutLocale, + /// The name of the template that should be used. + pub entity_name: String, /// 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 @@ -56,22 +64,61 @@ pub struct RouteInfoAtomic<'a, G: Html> { /// The locale for the template to be rendered in. pub locale: String, } +impl RouteInfo { + /// Converts this [`RouteInfo`] into a [`FullRouteInfo`]. + /// + /// # Panics + /// This will panic if the entity name held by `Self` is not in the given + /// map, which is only a concern if you `Self` didn't come from + /// `match_route`. + pub(crate) fn into_full<G: Html>(self, entities: &EntityMap<G>) -> FullRouteInfo<G> { + let entity = entities.get(&self.entity_name).expect("conversion to full route info failed, given entities did not contain given entity name"); + FullRouteInfo { + path: self.path, + entity, + was_incremental_match: self.was_incremental_match, + locale: self.locale, + } + } +} -/// 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). +/// The possible outcomes of matching a route in an app. /// -/// This version uses `RouteInfoAtomic`, and is designed for multithreaded -/// scenarios (i.e. on the server). -#[derive(Debug)] -pub enum RouteVerdictAtomic<'a, G: Html> { +/// Unlike [`FullRouteVerdict`], this does not store the actual template being +/// used, instead it only stores its name, making it much easier to store. +#[derive(Clone, Debug)] +pub enum RouteVerdict { /// The given route was found, and route information is attached. - Found(RouteInfoAtomic<'a, G>), + Found(RouteInfo), /// The given route was not found, and a `404 Not Found` page should be - /// shown. - NotFound, + /// shown. In apps using i18n, an invalid page without a locale will + /// first be redirected, before being later resolved as 404. Hence, + /// we can always provide a locale here, allowing the error view to be + /// appropriately translated. (I.e. there will never be a non-localized + /// 404 page in Perseus.) + NotFound { + /// The active locale. + locale: String, + }, /// The given route maps to the locale detector, which will redirect the /// user to the attached path (in the appropriate locale). - LocaleDetection(String), + /// + /// The attached path will have the appropriate locale prepended during the + /// detection process. + LocaleDetection(PathWithoutLocale), +} +impl RouteVerdict { + /// Converts this [`RouteVerdict`] into a [`FullRouteVerdict`]. + /// + /// # Panics + /// This will panic if the entity name held by `Self` is not in the given + /// map, which is only a concern if you `Self` didn't come from + /// `match_route` (this only applies when `Self` is `Self::Found(..)`). + pub(crate) fn into_full<G: Html>(self, entities: &EntityMap<G>) -> FullRouteVerdict<G> { + match self { + Self::Found(info) => FullRouteVerdict::Found(info.into_full(entities)), + Self::NotFound { locale } => FullRouteVerdict::NotFound { locale }, + Self::LocaleDetection(dest) => FullRouteVerdict::LocaleDetection(dest), + } + } } diff --git a/packages/perseus/src/router/router_component.rs b/packages/perseus/src/router/router_component.rs deleted file mode 100644 index 59ab0b7526..0000000000 --- a/packages/perseus/src/router/router_component.rs +++ /dev/null @@ -1,383 +0,0 @@ -use crate::{ - checkpoint, - i18n::detect_locale, - i18n::Locales, - router::{ - get_initial_view, get_subsequent_view, GetSubsequentViewProps, InitialView, - RouterLoadState, SubsequentView, - }, - router::{PerseusRoute, RouteInfo, RouteManager, RouteVerdict}, - template::{RenderCtx, TemplateMap, TemplateNodeType}, - utils::get_path_prefix_client, - ErrorPages, -}; -use std::collections::HashMap; -use std::rc::Rc; -use sycamore::{ - prelude::{ - component, create_effect, create_ref, create_signal, on_mount, view, ReadSignal, Scope, - View, - }, - Prop, -}; -use sycamore_futures::spawn_local_scoped; -use sycamore_router::{HistoryIntegration, RouterBase}; -use web_sys::Element; - -// We don't want to bring in a styling library, so we do this the old-fashioned -// way! We're particularly comprehensive with these because the user could -// *potentially* stuff things up with global rules https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe -const ROUTE_ANNOUNCER_STYLES: &str = r#" - margin: -1px; - padding: 0; - border: 0; - clip: rect(0 0 0 0); - height: 1px; - width: 1px; - overflow: hidden; - position: absolute; - white-space: nowrap; - word-wrap: normal; -"#; - -/// Get the view we should be rendering at the moment, based on a route verdict. -/// This should be called on every route change to update the page. This will -/// return the view it returns, and may add to the scope disposers. Note that -/// this may return error pages. -/// -/// This function is designed for managing subsequent views only, since the -/// router component should be instantiated *after* the initial view -/// has been hydrated. -/// -/// If the page needs to redirect to a particular locale, then this function -/// will imperatively do so. -async fn set_view<'a>( - cx: Scope<'a>, - route_manager: &'a RouteManager<'a, TemplateNodeType>, - verdict: RouteVerdict<TemplateNodeType>, -) { - checkpoint("router_entry"); - match &verdict { - RouteVerdict::Found(RouteInfo { - path, - template, - locale, - was_incremental_match, - }) => { - let subsequent_view = get_subsequent_view(GetSubsequentViewProps { - cx, - route_manager, - path: path.clone(), - template: template.clone(), - was_incremental_match: *was_incremental_match, - locale: locale.clone(), - route_verdict: verdict, - }) - .await; - // Display any errors now - if let SubsequentView::Error(view) = subsequent_view { - route_manager.update_view(view); - } - } - // For subsequent loads, this should only be possible if the dev forgot `link!()` - RouteVerdict::LocaleDetection(path) => { - let render_ctx = RenderCtx::from_ctx(cx); - let dest = detect_locale(path.clone(), &render_ctx.locales); - // Since this is only for subsequent loads, we know the router is instantiated - // This shouldn't be a replacement navigation, since the user has deliberately - // navigated here - sycamore_router::navigate(&dest); - } - RouteVerdict::NotFound => { - let render_ctx = RenderCtx::from_ctx(cx); - checkpoint("not_found"); - // TODO Update the router state here (we need a path though...) - // This function only handles subsequent loads, so this is all we have - route_manager.update_view(render_ctx.error_pages.get_view_and_render_head( - cx, - "", - 404, - "not found", - None, - )) - } - } -} - -/// The properties that the router takes. -#[derive(Debug, Prop)] -pub(crate) struct PerseusRouterProps { - /// The error pages the app is using. - pub error_pages: Rc<ErrorPages<TemplateNodeType>>, - /// The locales settings the app is using. - pub locales: Locales, - /// The templates the app is using. - pub templates: TemplateMap<TemplateNodeType>, - /// The render configuration of the app (which lays out routing information, - /// among other things). - pub render_cfg: HashMap<String, String>, - /// The maximum size of the page state store, before pages are evicted - /// to save memory in the browser. - pub pss_max_size: usize, -} - -/// The Perseus router. This is used internally in the Perseus engine, and you -/// shouldn't need to access this directly unless you're building a custom -/// engine. Note that this actually encompasses your entire app, and takes no -/// child properties. -/// -/// Note: this deliberately has a snake case name, and should be called directly -/// with `cx` as the first argument, allowing the `AppRoute` generic -/// creates with `create_app_root!` to be provided easily. That given `cx` -/// property will be used for all context registration in the app. -#[component] -pub(crate) fn perseus_router( - cx: Scope, - PerseusRouterProps { - error_pages, - locales, - templates, - render_cfg, - pss_max_size, - }: PerseusRouterProps, -) -> View<TemplateNodeType> { - // Now create an instance of `RenderCtx`, which we'll insert into context and - // use everywhere throughout the app (this contains basically everything Perseus - // needs in terms of infrastructure) - let render_ctx = RenderCtx::new( - pss_max_size, - locales, // Pretty light - templates, // Already has `Rc`s - Rc::new(render_cfg), - error_pages, // Already in an `Rc` itself - ) - .set_ctx(cx); - - // Create the route manager (note: this is cheap to clone) - let route_manager = RouteManager::new(cx); - let route_manager = create_ref(cx, route_manager); - - // Get the current path, removing any base paths to avoid relative path locale - // redirection loops (in previous versions of Perseus, we used Sycamore to - // get the path, and it strips this out automatically) - // Note that this does work with full URL paths, because - // `get_path_prefix_client` does automatically get just the pathname - // component. - let path_prefix = get_path_prefix_client(); - let path = web_sys::window().unwrap().location().pathname().unwrap(); - let path = if path.starts_with(&path_prefix) { - path.strip_prefix(&path_prefix).unwrap() - } else { - &path - }; - // Prepare the initial view for hydration (because we have everything we need in - // global window variables, this can be synchronous) - let initial_view = get_initial_view(cx, path.to_string(), route_manager); - match initial_view { - // Any errors are simply returned, it's our responsibility to display them - InitialView::Error(view) => route_manager.update_view(view), - // If we need to redirect, then we'll create a fake view that will just execute that code - // (which we can guarantee to run after the router is ready) - InitialView::Redirect(dest) => { - let dest = dest.clone(); - on_mount(cx, move || { - sycamore_router::navigate_replace(&dest); - }); - } - // A successful render has already been displayed to the root - InitialView::Success => (), - }; - - // This allows us to not run the subsequent load code on the initial load (we - // need a separate one for the reload commander) - let is_initial = create_signal(cx, true); - let is_initial_reload_commander = create_signal(cx, true); - - // Create a `Route` to pass through Sycamore with the information we need - let route = PerseusRoute { - verdict: RouteVerdict::NotFound, - cx: Some(cx), - }; - - // Create a derived state for the route announcement - // We do this with an effect because we only want to update in some cases (when - // the new page is actually loaded) We also need to know if it's the first - // page (because we don't want to announce that, screen readers will get that - // one right) - let route_announcement = create_signal(cx, String::new()); - let mut is_first_page = true; // This is different from the first page load (this is the first page as a - // whole) - let load_state = render_ctx.router.get_load_state_rc(); - create_effect(cx, move || { - if let RouterLoadState::Loaded { path, .. } = &*load_state.get() { - if is_first_page { - // This is the first load event, so the next one will be for a new page (or at - // least something that we should announce, if this page reloads then the - // content will change, that would be from thawing) - is_first_page = false; - } else { - // TODO Validate approach with reloading - // A new page has just been loaded and is interactive (this event only fires - // after all rendering and hydration is complete) - // Set the announcer to announce the title, falling back to the first `h1`, and - // then falling back again to the path - let document = web_sys::window().unwrap().document().unwrap(); - // If the content of the provided element is empty, this will transform it into - // `None` - let make_empty_none = |val: Element| { - let val = val.inner_html(); - if val.is_empty() { - None - } else { - Some(val) - } - }; - let title = document - .query_selector("title") - .unwrap() - .and_then(make_empty_none); - let announcement = match title { - Some(title) => title, - None => { - let first_h1 = document - .query_selector("h1") - .unwrap() - .and_then(make_empty_none); - match first_h1 { - Some(val) => val, - // Our final fallback will be the path - None => path.to_string(), - } - } - }; - - route_announcement.set(announcement); - } - } - }); - - // Listen for changes to the reload commander and reload as appropriate - let router_state = &render_ctx.router; - create_effect(cx, move || { - router_state.reload_commander.track(); - // Using a tracker of the initial state separate to the main one is fine, - // because this effect is guaranteed to fire on page load (they'll both be set) - if *is_initial_reload_commander.get_untracked() { - is_initial_reload_commander.set(false); - } else { - // Get the route verdict and re-run the function we use on route changes - // This has to be untracked, otherwise we get an infinite loop that will - // actually break client browsers (I had to manually kill Firefox...) - // TODO Investigate how the heck this actually caused an infinite loop... - let verdict = router_state.get_last_verdict(); - let verdict = match verdict { - Some(verdict) => verdict, - // If the first page hasn't loaded yet, terminate now - None => return, - }; - spawn_local_scoped(cx, async move { - set_view(cx, route_manager, verdict.clone()).await; - }); - } - }); - - // This section handles live reloading and HSR freezing - // We used to have an indicator shared to the macros, but that's no longer used - #[cfg(all(feature = "live-reload", debug_assertions))] - { - use crate::state::Freeze; - // Set up a oneshot channel that we can use to communicate with the WS system - // Unfortunately, we can't share senders/receivers around without bringing in - // another crate And, Sycamore's `RcSignal` doesn't like being put into - // a `Closure::wrap()` one bit - let (live_reload_tx, live_reload_rx) = futures::channel::oneshot::channel(); - crate::spawn_local_scoped(cx, async move { - match live_reload_rx.await { - // This will trigger only once, and then can't be used again - // That shouldn't be a problem, because we'll reload immediately - Ok(_) => { - #[cfg(all(feature = "hsr"))] - { - let frozen_state = render_ctx.freeze(); - crate::state::hsr_freeze(frozen_state).await; - } - crate::state::force_reload(); - // We shouldn't ever get here unless there was an error, the - // entire page will be fully reloaded - } - _ => (), - } - }); - - // If live reloading is enabled, connect to the server now - // This doesn't actually perform any reloading or the like, it just signals - // places that have access to the render context to do so (because we need that - // for state freezing/thawing) - crate::state::connect_to_reload_server(live_reload_tx); - } - - // This handles HSR thawing - #[cfg(all(feature = "hsr", debug_assertions))] - { - crate::spawn_local_scoped(cx, async move { - // We need to make sure we don't run this more than once, because that would - // lead to a loop It also shouldn't run on any pages after the - // initial load - if render_ctx.is_first.get() { - render_ctx.is_first.set(false); - crate::state::hsr_thaw(&render_ctx).await; - } - }); - }; - - // Append the route announcer to the end of the document body - let document = web_sys::window().unwrap().document().unwrap(); - let announcer = document.create_element("p").unwrap(); - announcer.set_attribute("aria-live", "assertive").unwrap(); - announcer.set_attribute("role", "alert").unwrap(); - announcer - .set_attribute("style", ROUTE_ANNOUNCER_STYLES) - .unwrap(); - announcer.set_id("__perseus_route_announcer"); - let body_elem: Element = document.body().unwrap().into(); - body_elem - .append_with_node_1(&announcer.clone().into()) - .unwrap(); - // Update the announcer's text whenever the `route_announcement` changes - create_effect(cx, move || { - let ra = route_announcement.get(); - announcer.set_inner_html(&ra); - }); - - view! { cx, - // This is a lower-level version of `Router` that lets us provide a `Route` with the data we want - RouterBase( - integration = HistoryIntegration::new(), - route = route, - view = move |cx, route: &ReadSignal<PerseusRoute>| { - // Do this on every update to the route, except the first time, when we'll use the initial load - create_effect(cx, move || { - route.track(); - - if *is_initial.get_untracked() { - is_initial.set(false); - } else { - spawn_local_scoped(cx, async move { - let route = route.get(); - let verdict = route.get_verdict(); - set_view(cx, route_manager, verdict.clone()).await; - }); - } - }); - - // This template is reactive, and will be updated as necessary - // However, the server has already rendered initial load content elsewhere, so we move that into here as well in the app shell - // The main reason for this is that the router only intercepts click events from its children - - view! { cx, - (*route_manager.view.get()) - } - } - ) - } -} diff --git a/packages/perseus/src/router/router_state.rs b/packages/perseus/src/router/router_state.rs index 253e6e129f..4921b2bd89 100644 --- a/packages/perseus/src/router/router_state.rs +++ b/packages/perseus/src/router/router_state.rs @@ -1,19 +1,19 @@ use super::RouteVerdict; -use crate::template::TemplateNodeType; +use crate::{path::PathMaybeWithLocale, router::RouteInfo}; use std::cell::RefCell; use std::rc::Rc; use sycamore::prelude::{create_rc_signal, create_ref, RcSignal, Scope}; /// The state for the router. This makes use of `RcSignal`s internally, and can /// be cheaply cloned. -#[derive(Debug, Clone)] +#[derive(Clone, Debug)] pub struct RouterState { /// The router's current load state. This is in an `RcSignal` because users /// need to be able to create derived state from it. load_state: RcSignal<RouterLoadState>, /// The last route verdict. We can come back to this if we need to reload /// the current page without losing context etc. - last_verdict: Rc<RefCell<Option<RouteVerdict<TemplateNodeType>>>>, + last_verdict: Rc<RefCell<Option<RouteVerdict>>>, /// A flip-flop `RcSignal`. Whenever this is changed, the router will reload /// the current page in the SPA style (maintaining state). As a user, you /// should rarely ever need to do this, but it's used internally in the @@ -21,8 +21,8 @@ pub struct RouterState { pub(crate) reload_commander: RcSignal<bool>, } impl Default for RouterState { - /// Creates a default instance of the router state intended for server-side - /// usage. + /// Creates a default instance of the router state intended for usage at the + /// startup of an app. fn default() -> Self { Self { load_state: create_rc_signal(RouterLoadState::Server), @@ -49,15 +49,21 @@ impl RouterState { self.load_state.clone() // TODO Better approach than cloning here? } /// Sets the load state of the router. - pub fn set_load_state(&self, new: RouterLoadState) { + /// + /// The router state upholds a number of invariants, and allowing the user + /// control of this could lead to unreachable code being executed. + pub(crate) fn set_load_state(&self, new: RouterLoadState) { self.load_state.set(new); } /// Gets the last verdict. - pub fn get_last_verdict(&self) -> Option<RouteVerdict<TemplateNodeType>> { + pub fn get_last_verdict(&self) -> Option<RouteVerdict> { (*self.last_verdict.borrow()).clone() } /// Sets the last verdict. - pub fn set_last_verdict(&self, new: RouteVerdict<TemplateNodeType>) { + /// + /// The router state upholds a number of invariants, and allowing the user + /// control of this could lead to unreachable code being executed. + pub(crate) fn set_last_verdict(&self, new: RouteVerdict) { let mut last_verdict = self.last_verdict.borrow_mut(); *last_verdict = Some(new); } @@ -71,6 +77,24 @@ impl RouterState { self.reload_commander .set(!*self.reload_commander.get_untracked()) } + /// Gets the current path within the app, including the locale if the app is + /// using i18n. This will not have a leading/trailing forward slash. + /// + /// If you're executing this from within a page, it will always be + /// `Some(..)`. `None` will be returned if no page has been rendered yet + /// (if you managed to call this from a plugin...), or, more likely, if + /// an error occurred (i.e. this will probably be `None` in error pages, + /// which are given the path anyway), or if we're diverting to a + /// localized version of the current path (in which case + /// your code should not be running). + pub fn get_path(&self) -> Option<PathMaybeWithLocale> { + let verdict = self.last_verdict.borrow(); + if let Some(RouteVerdict::Found(RouteInfo { path, locale, .. })) = &*verdict { + Some(PathMaybeWithLocale::new(path, locale)) + } else { + None + } + } } /// The current load state of the router. You can use this to be warned of when @@ -84,13 +108,13 @@ pub enum RouterLoadState { template_name: String, /// The full path to the new page being loaded (including the locale, if /// we're using i18n). - path: String, + path: PathMaybeWithLocale, }, - /// An error page has been loaded. + /// An error page has been loaded. Note that this will not account for any + /// popup errors. ErrorLoaded { - /// The full path to the page we intended to load, on which the error - /// occurred (including the locale, if we're using i18n). - path: String, + /// The path of this error. + path: PathMaybeWithLocale, }, /// A new page is being loaded, and will soon replace whatever is currently /// loaded. The name of the new template is attached. @@ -99,10 +123,11 @@ pub enum RouterLoadState { template_name: String, /// The full path to the new page being loaded (including the locale, if /// we're using i18n). - path: String, + path: PathMaybeWithLocale, }, - /// We're on the server, and there is no router. Whatever you render based - /// on this state will appear when the user first loads the page, before - /// it's made interactive. + /// We're still warming up, and the router state hasn't been updated yet. As + /// the router doesn't actually exist on the engine-side, this won't + /// appear on the engine-side, since the type is target-gated to the + /// browser-side. Server, } diff --git a/packages/perseus/src/server/build_error_page.rs b/packages/perseus/src/server/build_error_page.rs deleted file mode 100644 index f1ed656ec7..0000000000 --- a/packages/perseus/src/server/build_error_page.rs +++ /dev/null @@ -1,41 +0,0 @@ -use super::HtmlShell; -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). -/// -/// This doesn't inject translations of any sort, deliberately, since -/// we can't ensure that they would even exist --- this is used for all -/// types of server-side errors. -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<Rc<Translator>>, - error_pages: &ErrorPages<SsrNode>, - html_shell: &HtmlShell, -) -> String { - let error_html = error_pages.render_to_string(url, status, err, translator.clone()); - let error_head = error_pages.render_head(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 = ErrorPageData { - url: url.to_string(), - status, - err: err.to_string(), - }; - - html_shell - .clone() - .error_page(&error_page_data, &error_html, &error_head) - .to_string() -} diff --git a/packages/perseus/src/server/get_render_cfg.rs b/packages/perseus/src/server/get_render_cfg.rs deleted file mode 100644 index 19280da836..0000000000 --- a/packages/perseus/src/server/get_render_cfg.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::errors::*; -use crate::stores::ImmutableStore; -use std::collections::HashMap; - -/// Gets the configuration of how to render each page using an immutable store. -/// -/// The render configuration is an internal build artifact stored somewhere like -/// `dist/`, generated automatically by the build process. The server provides -/// it automatically to the client to optimize routing. -pub async fn get_render_cfg( - immutable_store: &ImmutableStore, -) -> Result<HashMap<String, String>, ServerError> { - let content = immutable_store.read("render_conf.json").await?; - let cfg = serde_json::from_str::<HashMap<String, String>>(&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/html_shell.rs b/packages/perseus/src/server/html_shell.rs index bba979bd05..b166240cc7 100644 --- a/packages/perseus/src/server/html_shell.rs +++ b/packages/perseus/src/server/html_shell.rs @@ -1,8 +1,6 @@ -use fmterr::fmterr; - -use crate::error_pages::ErrorPageData; +use crate::error_views::ServerErrorData; use crate::page_data::PageData; -use crate::template::TemplateState; +use crate::state::TemplateState; use crate::utils::minify; use std::collections::HashMap; use std::{env, fmt}; @@ -23,7 +21,7 @@ fn escape_page_data(data: &str) -> String { /// scripts and content defined by the user, components of the Perseus core, and /// plugins. #[derive(Clone, Debug)] -pub struct HtmlShell { +pub(crate) struct HtmlShell { /// The actual shell content, on which interpolations will be performed. pub shell: String, /// Additional contents of the head before the interpolation boundary. @@ -53,7 +51,7 @@ pub struct HtmlShell { impl HtmlShell { /// Initializes the HTML shell by interpolating necessary scripts into it /// and adding the render configuration. - pub fn new( + pub(crate) fn new( shell: String, root_id: &str, render_cfg: &HashMap<String, String>, @@ -152,7 +150,7 @@ impl HtmlShell { /// translator can be derived on the client-side. These are provided in /// a window variable to avoid page interactivity requiring a network /// request to get them. - pub fn page_data( + pub(crate) fn page_data( mut self, page_data: &PageData, global_state: &TemplateState, @@ -163,6 +161,9 @@ impl HtmlShell { // doesn't contaminate later non-initial loads Error pages (above) will // set this to `error` let initial_state = escape_page_data(&page_data.state.to_string()); + // We know the form of this, and it won't fail + let initial_widget_states = + escape_page_data(&serde_json::to_string(&page_data.widget_states).unwrap()); let global_state = escape_page_data(&global_state.state.to_string()); let translations = escape_page_data(translations); @@ -170,6 +171,11 @@ impl HtmlShell { // it doesn't matter if it's expunged on subsequent loads let initial_state = format!("window.__PERSEUS_INITIAL_STATE = `{}`;", initial_state); self.scripts_after_boundary.push(initial_state); + let initial_widget_states = format!( + "window.__PERSEUS_INITIAL_WIDGET_STATES = `{}`;", + initial_widget_states + ); + self.scripts_after_boundary.push(initial_widget_states); // But we'll need the global state as a variable until a template accesses it, // so we'll keep it around (even though it should actually instantiate validly // and not need this after the initial load) @@ -205,7 +211,7 @@ impl HtmlShell { /// /// Further, this will preload the Wasm binary, making redirection snappier /// (but initial load slower), a tradeoff that generally improves UX. - pub fn locale_redirection_fallback(mut self, redirect_url: &str) -> Self { + pub(crate) fn locale_redirection_fallback(mut self, redirect_url: &str) -> Self { // This will be used if JavaScript is completely disabled (it's then the site's // responsibility to show a further message) let dumb_redirect = format!( @@ -264,15 +270,16 @@ impl HtmlShell { /// Interpolates page error data into the shell in the event of a failure. /// - /// Importantly, this makes no assumptions about the availability of - /// translations, so error pages rendered from here will not be - /// internationalized. - // TODO Provide translations where we can at least? - pub fn error_page( + /// This takes an optional translations string if it's available, injecting + /// it if possible. If the reactor finds this variable to be empty on an + /// error extracted from the initial state variable, it will assume the + /// error is unlocalized. + pub(crate) fn error_page( mut self, - error_page_data: &ErrorPageData, + error_page_data: &ServerErrorData, error_html: &str, error_head: &str, + translations_str: Option<&str>, ) -> Self { let error = serde_json::to_string(error_page_data).unwrap(); let state_var = format!( @@ -280,6 +287,12 @@ impl HtmlShell { escape_page_data(&error), ); self.scripts_after_boundary.push(state_var); + + if let Some(translations) = translations_str { + let translations = format!("window.__PERSEUS_TRANSLATIONS = `{}`;", translations); + self.scripts_after_boundary.push(translations); + } + self.head_after_boundary.push(error_head.to_string()); self.content = error_html.into(); @@ -318,9 +331,16 @@ impl fmt::Display for HtmlShell { let body_start = self.before_content.join("\n"); let body_end = self.after_content.join("\n"); + // We also insert the popup error handler here let shell_with_body = shell_with_head .replace("<body>", &format!("<body>{}", body_start)) - .replace("</body>", &format!("{}</body>", body_end)); + .replace( + "</body>", + &format!( + "{}<div id=\"__perseus_popup_error\"></div></body>", + body_end + ), + ); // The user MUST place have a `<div>` of this exact form (documented explicitly) // We permit either double or single quotes @@ -342,10 +362,7 @@ impl fmt::Display for HtmlShell { // can't minify, we'll fall back to unminified) let minified = match minify(&new_shell, true) { Ok(minified) => minified, - Err(err) => { - eprintln!("{}", fmterr(&err)); - new_shell - } + Err(_) => new_shell, }; f.write_str(&minified) diff --git a/packages/perseus/src/server/mod.rs b/packages/perseus/src/server/mod.rs index 3e9085daaa..f1f6db0c84 100644 --- a/packages/perseus/src/server/mod.rs +++ b/packages/perseus/src/server/mod.rs @@ -4,17 +4,11 @@ //! integrations. Apart from building your own integrations, you should never //! need to use this module (though some plugins may need types in here). -mod build_error_page; -mod get_render_cfg; mod html_shell; mod options; -mod render; -pub use build_error_page::build_error_page; -pub use get_render_cfg::get_render_cfg; -pub use html_shell::HtmlShell; -pub use options::{ServerOptions, ServerProps}; -pub use render::{get_page, get_page_for_template, GetPageProps}; +pub(crate) use html_shell::HtmlShell; +pub use options::ServerOptions; /// Removes empty elements from a path, which is important due to double /// slashes. This returns a vector of the path's components; diff --git a/packages/perseus/src/server/options.rs b/packages/perseus/src/server/options.rs index f76be0145f..ba1b39bc81 100644 --- a/packages/perseus/src/server/options.rs +++ b/packages/perseus/src/server/options.rs @@ -1,15 +1,3 @@ -use crate::error_pages::ErrorPages; -use crate::i18n::Locales; -use crate::i18n::TranslationsManager; -use crate::state::GlobalStateCreator; -use crate::stores::{ImmutableStore, MutableStore}; -use crate::template::ArcTemplateMap; -use crate::SsrNode; -use std::collections::HashMap; -use std::sync::Arc; - -use super::HtmlShell; - /// 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. @@ -23,45 +11,20 @@ pub struct ServerOptions { /// Wasm bundle. This isn't required, and if you haven't generated this, you /// should provide a fake path. pub wasm_js_bundle: String, - /// The HTML shell to interpolate Perseus into. - pub html_shell: HtmlShell, - /// A `HashMap` of your app's templates by their paths. - pub templates_map: ArcTemplateMap<SsrNode>, - /// 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 - /// `<div id="root_id">` 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: Arc<ErrorPages<SsrNode>>, - /// The directory to serve static content from, which will be mapped to - /// `/.perseus/static`in the browser. - pub static_dir: Option<String>, - /// 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<String, String>, } - -/// The full set of properties that all server integrations take. -#[derive(Debug, Clone)] -pub struct ServerProps<M: MutableStore, T: TranslationsManager> { - /// 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, - /// The global state creator. This is used to avoid issues with `async` and - /// cloning in Actix Web. - pub global_state_creator: Arc<GlobalStateCreator>, +#[cfg(feature = "dflt-engine")] +impl Default for ServerOptions { + fn default() -> Self { + Self { + 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(), + // This probably won't exist, but on the off chance that the user needs to support older + // browsers, we'll provide it anyway + wasm_js_bundle: "dist/pkg/perseus_engine_bg.wasm.js".to_string(), + snippets: "dist/pkg/snippets".to_string(), + } + } } diff --git a/packages/perseus/src/server/render.rs b/packages/perseus/src/server/render.rs deleted file mode 100644 index 91e54bdd3a..0000000000 --- a/packages/perseus/src/server/render.rs +++ /dev/null @@ -1,740 +0,0 @@ -use crate::errors::*; -use crate::i18n::TranslationsManager; -use crate::page_data::PageData; -use crate::state::GlobalStateCreator; -use crate::stores::{ImmutableStore, MutableStore}; -use crate::template::{ - StateGeneratorInfo, States, Template, TemplateMap, TemplateState, UnknownStateType, -}; -use crate::translator::Translator; -use crate::Request; -use crate::SsrNode; -use chrono::{DateTime, Utc}; - -/// Clones a `Request` from its internal parts. -fn clone_req(raw: &Request) -> Request { - let mut builder = Request::builder(); - - for (name, val) in raw.headers() { - builder = builder.header(name, val); - } - - builder - .uri(raw.uri()) - .method(raw.method()) - .version(raw.version()) - // We always use an empty body because, in a Perseus request, only the URI matters - // Any custom data should therefore be sent in headers (if you're doing that, consider a - // dedicated API) - .body(()) - .unwrap() // This should never fail... -} - -/// Gets the path with the locale, returning it without if i18n isn't being -/// used. -fn get_path_with_locale(path_without_locale: &str, translator: &Translator) -> String { - let locale = translator.get_locale(); - match locale.as_str() { - "xx-XX" => path_without_locale.to_string(), - locale => format!("{}/{}", locale, path_without_locale), - } -} - -/// 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. -/// -/// This returns a body, head, and state, since all are from stores. -async fn render_build_state( - path_encoded: &str, - immutable_store: &ImmutableStore, - render_html: bool, -) -> Result<(String, String, TemplateState), ServerError> { - // Get the static HTML - let html = if render_html { - immutable_store - .read(&format!("static/{}.html", path_encoded)) - .await? - } else { - String::new() - }; - let head = immutable_store - .read(&format!("static/{}.head.html", path_encoded)) - .await?; - // Get the static JSON - let state = match immutable_store - .read(&format!("static/{}.json", path_encoded)) - .await - { - Ok(state) => TemplateState::from_str(&state) - .map_err(|err| ServerError::InvalidPageState { source: err })?, - Err(_) => TemplateState::empty(), - }; - - Ok((html, head, state)) -} -/// Renders a template that uses state generated at build-time. This is -/// specifically for page that revalidate, because they store data -/// in the mutable store. -/// -/// This returns a body, head, and state, since all are from stores. -async fn render_build_state_for_mutable( - path_encoded: &str, - mutable_store: &impl MutableStore, - render_html: bool, -) -> Result<(String, String, TemplateState), ServerError> { - // Get the static HTML - let html = if render_html { - mutable_store - .read(&format!("static/{}.html", path_encoded)) - .await? - } else { - String::new() - }; - let head = mutable_store - .read(&format!("static/{}.head.html", path_encoded)) - .await?; - // Get the static JSON - let state = match mutable_store - .read(&format!("static/{}.json", path_encoded)) - .await - { - Ok(state) => TemplateState::from_str(&state) - .map_err(|err| ServerError::InvalidPageState { source: err })?, - Err(_) => TemplateState::empty(), - }; - - Ok((html, head, state)) -} -/// Renders a template that generated its state at request-time. Note that -/// revalidation and incremental generation have no impact on SSR-rendered -/// pages. This does everything at request-time, and so doesn't need a mutable -/// or immutable store. -/// -/// As this involves state computation, this only returns the state. -async fn get_request_state( - template: &Template<SsrNode>, - build_info: StateGeneratorInfo<UnknownStateType>, - req: Request, -) -> Result<TemplateState, ServerError> { - // Generate the initial state (this may generate an error, but there's no file - // that can't exist) - let state = template.get_request_state(build_info, req).await?; - - Ok(state) -} -/// Renders a template that wants to amalgamate build state with request state. -/// This does everything at request-time, and so doesn't need a mutable or -/// immutable store. -/// -/// As this is always the final item, this returns a body and head along with -/// the state. -async fn render_amalgamated_state( - template: &Template<SsrNode>, - build_info: StateGeneratorInfo<UnknownStateType>, - translator: &Translator, - global_state: &TemplateState, - build_state: TemplateState, - request_state: TemplateState, - render_html: bool, -) -> Result<(String, String, TemplateState), ServerError> { - let path_with_locale = get_path_with_locale(&build_info.path, &translator); - // Generate the initial state (this may generate an error, but there's no file - // that can't exist) - let state = template - .amalgamate_states(build_info, build_state, request_state) - .await?; - - let html = if render_html { - sycamore::render_to_string(|cx| { - template.render_for_template_server( - path_with_locale, - state.clone(), - global_state.clone(), - cx, - translator, - ) - }) - } else { - String::new() - }; - let head = template.render_head_str(state.clone(), global_state.clone(), translator); - - Ok((html, head, state)) -} -/// Checks if a template that uses incremental generation has already been -/// cached. If the template was prerendered by *build paths*, then it will have -/// already been matched because those are declared verbatim in the render -/// configuration. Therefore, this function only searches for pages that have -/// been cached later, which means it needs a mutable store. -/// -/// This returns a body and a head. -/// -/// This accepts a `render_html` directive because, if it needed to cache -/// anything, then it would return `None`, and that's handled outside this -/// function. -async fn get_incremental_cached( - path_encoded: &str, - mutable_store: &impl MutableStore, - render_html: bool, -) -> Option<(String, String)> { - let html_res = if render_html { - mutable_store - .read(&format!("static/{}.html", path_encoded)) - .await - } else { - Ok(String::new()) - }; - - // We should only treat it as cached if it can be accessed and if we aren't in - // development (when everything should constantly reload) - match html_res { - Ok(html) if !cfg!(debug_assertions) => { - // If the HTML exists, the head must as well - let head = mutable_store - .read(&format!("static/{}.head.html", path_encoded)) - .await - .unwrap(); - Some((html, head)) - } - Ok(_) | Err(_) => None, - } -} -/// Checks if a template should revalidate by time. All revalidation timestamps -/// are stored in a mutable store, so that's what this function uses. -async fn should_revalidate( - template: &Template<SsrNode>, - path_encoded: &str, - mutable_store: &impl MutableStore, - build_info: StateGeneratorInfo<UnknownStateType>, - req: Request, -) -> Result<bool, ServerError> { - let mut should_revalidate = false; - // If it revalidates after a certain period of time, we need to check that - // BEFORE the custom logic - if template.revalidates_with_time() { - // Get the time when it should revalidate (RFC 3339) - // This will be updated, so it's in a mutable store - let datetime_to_revalidate_str = mutable_store - .read(&format!("static/{}.revld.txt", path_encoded)) - .await?; - let datetime_to_revalidate = DateTime::parse_from_rfc3339(&datetime_to_revalidate_str) - .map_err(|e| { - let serve_err: ServeError = e.into(); - serve_err - })?; - // Get the current time (UTC) - let now = Utc::now(); - - // If the datetime to revalidate is still in the future, end with `false` - if datetime_to_revalidate > now { - return Ok(false); - } - should_revalidate = true; - } - - // Now run the user's custom revalidation logic - if template.revalidates_with_logic() { - should_revalidate = template.should_revalidate(build_info, req).await?; - } - Ok(should_revalidate) -} -/// 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. -/// -/// Despite this involving state computation, it needs to write a body and -/// head to the mutable store, so it returns those along with the state. -/// -/// This receives no directive about not rendering content HTML, since it -/// has to for future caching anyway. -async fn revalidate( - template: &Template<SsrNode>, - build_info: StateGeneratorInfo<UnknownStateType>, - translator: &Translator, - path_encoded: &str, - global_state: &TemplateState, - mutable_store: &impl MutableStore, -) -> Result<(String, String, TemplateState), ServerError> { - let path_with_locale = get_path_with_locale(&build_info.path, &translator); - // We need to regenerate and cache this page for future usage (until the next - // revalidation) - let state = template.get_build_state(build_info).await?; - let html = sycamore::render_to_string(|cx| { - template.render_for_template_server( - path_with_locale, - state.clone(), - global_state.clone(), - cx, - translator, - ) - }); - let head = template.render_head_str(state.clone(), global_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() { - // IMPORTANT: we set the new revalidation datetime to the interval from NOW, not - // from the previous one So if you're revalidating many pages weekly, - // they will NOT revalidate simultaneously, even if they're all queried thus - let datetime_to_revalidate = template - .get_revalidate_interval() - .unwrap() - .compute_timestamp(); - mutable_store - .write( - &format!("static/{}.revld.txt", path_encoded), - &datetime_to_revalidate, - ) - .await?; - } - mutable_store - .write( - &format!("static/{}.json", path_encoded), - &state.state.to_string(), - ) - .await?; - mutable_store - .write(&format!("static/{}.html", path_encoded), &html) - .await?; - mutable_store - .write(&format!("static/{}.head.html", path_encoded), &head) - .await?; - - Ok((html, head, state)) -} - -/// The properties required to get data for a page. -#[derive(Debug)] -pub struct GetPageProps<'a, M: MutableStore, T: TranslationsManager> { - /// The raw path (which must not contain the locale). - pub raw_path: &'a str, - /// The locale to render for. - pub locale: &'a str, - /// Whether or not the page was matched on a template using incremental - /// generation that didn't prerender it with build paths (these use the - /// mutable store). - pub was_incremental_match: bool, - /// The request data. - pub req: Request, - /// The pre-built global state. If the app does not generate global state - /// at build-time, then this will be an empty state. Importantly, we may - /// render request-time global state, or even amalgamate that with - /// build-time state. - /// - /// See `build.rs` for further details of the quirks involved in this - /// system. - pub global_state: &'a TemplateState, - /// The global state creator. - pub global_state_creator: &'a GlobalStateCreator, - /// An immutable store. - pub immutable_store: &'a ImmutableStore, - /// A mutable store. - pub mutable_store: &'a M, - /// A translations manager. - pub translations_manager: &'a T, -} - -/// Internal logic behind [`get_page`]. The only differences are that this takes -/// a full template rather than just a template name, which can avoid an -/// unnecessary lookup if you already know the template in full (e.g. initial -/// load server-side routing). Because this handles templates with potentially -/// revalidation and incremental generation, it uses both mutable and immutable -/// stores. -/// -/// This returns the [`PageData`] and the global state (which may have been -/// recomputed at request-time). -/// -/// If `render_html` is set to `false` here, then no content HTML will be -/// generated (designed for subsequent loads). -pub async fn get_page_for_template<M: MutableStore, T: TranslationsManager>( - GetPageProps { - raw_path, - locale, - was_incremental_match, - req, - global_state: built_global_state, - global_state_creator: gsc, - immutable_store, - mutable_store, - translations_manager, - }: GetPageProps<'_, M, T>, - template: &Template<SsrNode>, - render_html: bool, -) -> Result<(PageData, TemplateState), ServerError> { - // Since `Request` is not actually `Clone`able, we hack our way around needing - // it three times - // An `Rc` won't work because of future constraints, and an `Arc` - // seems a little unnecessary - // TODO This is ridiculous - let req_2 = clone_req(&req); - let req_3 = clone_req(&req); - // Get a translator for this locale (for sanity we hope the manager is caching) - let translator = translations_manager - .get_translator_for_locale(locale.to_string()) - .await?; - - // If necessary, generate request-time global state - let built_global_state = built_global_state.clone(); - let global_state = if gsc.uses_request_state() { - let req_state = gsc.get_request_state(locale.to_string(), req_3).await?; - // If we have a non-empty build-time state, we'll need to amalgamate - if !built_global_state.is_empty() { - if gsc.can_amalgamate_states() { - gsc.amalgamate_states(locale.to_string(), built_global_state, req_state) - .await? - } else { - // No amalgamation capability, requesttime state takes priority - req_state - } - } else { - req_state - } - } else { - // This global state is purely generated at build-time (or nonexistent) - built_global_state - }; - - let path = raw_path; - // Remove `/` from the path by encoding it as a URL (that's what we store) and - // add the locale - let path_encoded = format!("{}-{}", locale, urlencoding::encode(path)); - - // Get the extra build data for this template - let build_extra = match immutable_store - .read(&format!("static/{}.extra.json", template.get_path())) - .await - { - Ok(state) => { - TemplateState::from_str(&state).map_err(|err| ServerError::InvalidBuildExtra { - template_name: template.get_path(), - source: err, - })? - } - // If this happens, then the immutable store has been tampered with, since - // the build logic generates some kind of state for everything - Err(_) => { - return Err(ServerError::MissingBuildExtra { - template_name: template.get_path(), - }) - } - }; - let build_info = StateGeneratorInfo { - path: path.to_string(), - locale: locale.to_string(), - extra: build_extra, - }; - - let path_with_locale = get_path_with_locale(&build_info.path, &translator); - - // Only a single string of HTML is needed, and it will be overridden if - // necessary (priorities system) - // We might set this to something cached, and later override it - // This will only be populated if `render_html` is `true` - let mut html = String::new(); - // The same applies for the document metadata - let mut head = String::new(); - // Multiple rendering strategies may need to amalgamate different states - let mut states = States::new(); - - // Handle build state (which might use revalidation or incremental) - if template.uses_build_state() || template.is_basic() { - // If the template uses incremental generation, that is its own contained - // process - if template.uses_incremental() && was_incremental_match { - // This template uses incremental generation, and this page was built and cached - // at runtime in the mutable store Get the cached content if it - // exists (otherwise `None`) - let html_and_head_opt = - get_incremental_cached(&path_encoded, mutable_store, render_html).await; - match html_and_head_opt { - // It's cached - Some((html_val, head_val)) => { - // Check if we need to revalidate - if should_revalidate( - template, - &path_encoded, - mutable_store, - build_info.clone(), - req, - ) - .await? - { - let (html_val, head_val, state) = revalidate( - template, - build_info.clone(), - &translator, - &path_encoded, - &global_state, - mutable_store, - ) - .await?; - // That revalidation will have returned a body and head, which we can - // provisionally use - html = html_val; - head = head_val; - states.build_state = state; - } else { - // That incremental cache check will have returned a body and head, which we - // can provisionally use - html = html_val; - head = head_val; - // Get the static JSON (if it exists, but it should) - // THis wouldn't be present if the user had set up incremental generation - // without build state (which would be remarkably silly) - states.build_state = match mutable_store - .read(&format!("static/{}.json", path_encoded)) - .await - { - Ok(state) => TemplateState::from_str(&state) - .map_err(|err| ServerError::InvalidPageState { source: err })?, - Err(_) => TemplateState::empty(), - }; - } - } - // It's not cached - // All this uses the mutable store because this will be done at runtime - None => { - // We need to generate and cache this page for future usage (even if - // `render_html` is `false`) Even if we're going to - // amalgamate later, we still have to perform incremental - // caching, which means a potentially unnecessary page build - let state = template.get_build_state(build_info.clone()).await?; - let html_val = sycamore::render_to_string(|cx| { - template.render_for_template_server( - path_with_locale.clone(), - state.clone(), - global_state.clone(), - cx, - &translator, - ) - }); - let head_val = - template.render_head_str(state.clone(), global_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 - if template.revalidates_with_time() { - let datetime_to_revalidate = template - .get_revalidate_interval() - .unwrap() - .compute_timestamp(); - // Write that to a static file, we'll update it every time we revalidate - // Note that this runs for every path generated, so it's fully usable with - // ISR - mutable_store - .write( - &format!("static/{}.revld.txt", path_encoded), - &datetime_to_revalidate, - ) - .await?; - } - // Cache all that - mutable_store - .write( - &format!("static/{}.json", path_encoded), - &state.state.to_string(), - ) - .await?; - // Write that prerendered HTML to a static file - mutable_store - .write(&format!("static/{}.html", path_encoded), &html_val) - .await?; - mutable_store - .write(&format!("static/{}.head.html", path_encoded), &head_val) - .await?; - - states.build_state = state; - html = html_val; - head = head_val; - } - } - } else { - // If we're here, incremental generation is either not used or it's irrelevant - // because the page was rendered in the immutable store at build time - - // Handle if we need to revalidate - // It'll be in the mutable store if we do - if should_revalidate( - template, - &path_encoded, - mutable_store, - build_info.clone(), - req, - ) - .await? - { - let (html_val, head_val, state) = revalidate( - template, - build_info.clone(), - &translator, - &path_encoded, - &global_state, - mutable_store, - ) - .await?; - // That revalidation will have produced a head and body, which we can - // provisionally use - html = html_val; - head = head_val; - states.build_state = state; - } else if template.revalidates() { - // The template does revalidate, but it doesn't need to revalidate now - // Nonetheless, its data will be the mutable store - // This is just fetching, not computing - let (html_val, head_val, state) = - render_build_state_for_mutable(&path_encoded, mutable_store, render_html) - .await?; - html = html_val; - head = head_val; - states.build_state = state; - } else { - // If we don't need to revalidate and this isn't an incrementally generated - // template, everything is immutable - // Again, this just fetches - let (html_val, head_val, state) = - render_build_state(&path_encoded, immutable_store, render_html).await?; - html = html_val; - head = head_val; - states.build_state = state; - } - } - } - // Handle request state - if template.uses_request_state() { - // Because this never needs to write to a file or the like, this just generates - // the state We can therefore avoid an unnecessary page build in - // templates with state amalgamation If we're using amalgamation, the - // page will be built soon If we're not, and there's no build state, - // then we still need to build, which we'll do after we've checked for - // amalgamation - let state = get_request_state(template, build_info.clone(), req_2).await?; - states.request_state = state; - } - - // Amalgamate the states - // If the user has defined custom logic for this, we'll defer to that - // Otherwise, request trumps build - // Of course, if only one state was defined, we'll just use that regardless - // - // If we're not using amalgamation, and only the request state is defined, then - // we still need to build the page. We don't do that earlier so we can avoid - // double-building with amalgamation. - let state = if !states.both_defined() && template.uses_request_state() { - // If we only have one state, and it's from request time, then we need to build - // the template with it now - let state = states.get_defined()?; - - let head_val = template.render_head_str(state.clone(), global_state.clone(), &translator); - head = head_val; - // We should only render the HTML if necessary, since we're not caching - if render_html { - let html_val = sycamore::render_to_string(|cx| { - template.render_for_template_server( - path_with_locale, - state.clone(), - global_state.clone(), - cx, - &translator, - ) - }); - html = html_val; - } - state - } else if !states.both_defined() { - // If we only have one state, and it's not from request time, then we've already - // built. If there is any state at all, this will be `Some(state)`, - // otherwise `None` (if this template doesn't take state) - states.get_defined()? - } else if template.can_amalgamate_states() { - // We know that both the states are defined - // The HTML is currently built with the wrong state, so we have to update it - let (html_val, head_val, state) = render_amalgamated_state( - template, - build_info, - &translator, - &global_state, - states.build_state, - states.request_state, - render_html, - ) - .await?; - html = html_val; - head = head_val; - state - } else { - // We do have multiple states, but there's no resolution function, so we have to - // prefer request state - // That means we have to build the page for it, - // since we haven't yet - let state = states.request_state; - let head_val = template.render_head_str(state.clone(), global_state.clone(), &translator); - // We should only render the HTML if necessary, since we're not caching - if render_html { - let html_val = sycamore::render_to_string(|cx| { - template.render_for_template_server( - path_with_locale, - state.clone(), - global_state.clone(), - cx, - &translator, - ) - }); - html = html_val; - } - head = head_val; - state - }; - - // Combine everything into one JSON object - // If we aren't rendering content HTML, then we won't even bother including it - // (since it could actually have something in it, particularly from - // revalidation/incremental generation, which generates regardless for - // caching) - let res = if render_html { - PageData { - content: html, - state: state.state, - head, - } - } else { - PageData { - content: String::new(), - state: state.state, - head, - } - }; - - Ok((res, global_state)) -} - -/// Gets the HTML/JSON data for the given page path. This will call -/// SSG/SSR/etc., whatever is needed for that page. -/// -/// This returns the [`PageData`] and the global state (which may have been -/// recomputed at request-time). -pub async fn get_page<M: MutableStore, T: TranslationsManager>( - props: GetPageProps<'_, M, T>, - template_name: &str, - templates: &TemplateMap<SsrNode>, - render_html: bool, -) -> Result<(PageData, TemplateState), ServerError> { - let path = props.raw_path; - // Get the template to use - let template = templates.get(template_name); - let template = match template { - Some(template) => template, - // This shouldn't happen because the client should already have performed checks against the - // render config, but it's handled anyway - None => { - return Err(ServeError::PageNotFound { - path: path.to_string(), - } - .into()) - } - }; - - let res = get_page_for_template(props, template, render_html).await?; - Ok(res) -} diff --git a/packages/perseus/src/state/freeze.rs b/packages/perseus/src/state/freeze.rs index 7ad5e666ee..1d41fdbeab 100644 --- a/packages/perseus/src/state/freeze.rs +++ b/packages/perseus/src/state/freeze.rs @@ -1,3 +1,5 @@ +use super::global_state::FrozenGlobalState; +use crate::path::PathMaybeWithLocale; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -7,14 +9,19 @@ use std::collections::HashMap; /// seriously know what you're doing! #[derive(Serialize, Deserialize, Debug, Clone)] pub struct FrozenApp { - /// The frozen global state. If it was never initialized, this will be - /// `None`. - pub global_state: String, - /// The frozen route. - pub route: String, - /// The frozen page state store. We store this as a `HashMap` as this level + /// The frozen global state. This will serialize to a `TemplateState`, + /// unless it is set to + pub global_state: FrozenGlobalState, + /// The frozen route. This will be `None` if the app hadn't properly + /// hydrated when it was frozen. + pub route: Option<PathMaybeWithLocale>, + /// The frozen state store. We store this as a `HashMap` as this level /// so that we can avoid another deserialization. - pub page_state_store: HashMap<String, String>, + /// + /// Note that this only contains the active state store, preloads are *not* + /// preserved to save space (and because they can always be + /// re-instantiated ) + pub state_store: HashMap<PathMaybeWithLocale, String>, } /// The user's preferences on state thawing. @@ -48,9 +55,9 @@ pub enum PageThawPrefs { Exclude(Vec<String>), } impl PageThawPrefs { - /// Checks whether or not the given URl should prioritize frozen state over + /// Checks whether or not the given URL should prioritize frozen state over /// active state. - pub fn should_use_frozen_state(&self, url: &str) -> bool { + pub(crate) fn should_prefer_frozen_state(&self, url: &str) -> bool { match &self { // If we're only including some pages, this page should be on the include list Self::Include(pages) => pages.iter().any(|v| v == url), diff --git a/packages/perseus/src/state/global_state.rs b/packages/perseus/src/state/global_state.rs index 1ffb1d4afd..c628f0c02c 100644 --- a/packages/perseus/src/state/global_state.rs +++ b/packages/perseus/src/state/global_state.rs @@ -1,13 +1,23 @@ use super::rx_state::AnyFreeze; -use super::{Freeze, MakeRx, MakeUnrx}; +use super::TemplateState; +use super::{MakeRx, MakeUnrx}; #[cfg(not(target_arch = "wasm32"))] // To suppress warnings use crate::errors::*; -use crate::stores::ImmutableStore; -use crate::template::{RenderFnResult, TemplateState}; +use crate::errors::{ClientError, ClientInvariantError}; +#[cfg(not(target_arch = "wasm32"))] +use crate::make_async_trait; +#[cfg(not(target_arch = "wasm32"))] +use crate::template::{BlamedGeneratorResult, GeneratorResult}; +#[cfg(not(target_arch = "wasm32"))] use crate::utils::AsyncFnReturn; -use crate::{make_async_trait, RenderFnResultWithCause, Request}; +#[cfg(not(target_arch = "wasm32"))] +use crate::Request; +#[cfg(not(target_arch = "wasm32"))] use futures::Future; +#[cfg(not(target_arch = "wasm32"))] use serde::de::DeserializeOwned; +#[cfg(target_arch = "wasm32")] +use serde::Deserialize; use serde::Serialize; use std::cell::RefCell; use std::rc::Rc; @@ -15,20 +25,20 @@ use std::rc::Rc; #[cfg(not(target_arch = "wasm32"))] make_async_trait!( GlobalStateBuildFnType, - RenderFnResult<TemplateState>, + Result<TemplateState, ServerError>, locale: String ); #[cfg(not(target_arch = "wasm32"))] make_async_trait!( GlobalStateRequestFnType, - RenderFnResultWithCause<TemplateState>, + Result<TemplateState, ServerError>, locale: String, req: Request ); #[cfg(not(target_arch = "wasm32"))] make_async_trait!( GlobalStateAmalgamationFnType, - RenderFnResultWithCause<TemplateState>, + Result<TemplateState, ServerError>, locale: String, build_state: TemplateState, request_state: TemplateState @@ -36,21 +46,21 @@ make_async_trait!( #[cfg(not(target_arch = "wasm32"))] make_async_trait!( - GlobalStateBuildUserFnType<S: Serialize + DeserializeOwned + MakeRx>, - RenderFnResult<S>, + GlobalStateBuildUserFnType< S: Serialize + DeserializeOwned + MakeRx, V: Into< GeneratorResult<S> > >, + V, locale: String ); #[cfg(not(target_arch = "wasm32"))] make_async_trait!( - GlobalStateRequestUserFnType<S: Serialize + DeserializeOwned + MakeRx>, - RenderFnResultWithCause<S>, + GlobalStateRequestUserFnType< S: Serialize + DeserializeOwned + MakeRx, V: Into< BlamedGeneratorResult<S> > >, + V, locale: String, req: Request ); #[cfg(not(target_arch = "wasm32"))] make_async_trait!( - GlobalStateAmalgamationUserFnType<S: Serialize + DeserializeOwned + MakeRx>, - RenderFnResultWithCause<S>, + GlobalStateAmalgamationUserFnType< S: Serialize + DeserializeOwned + MakeRx, V: Into< BlamedGeneratorResult<S> > >, + V, locale: String, build_state: S, request_state: S @@ -101,17 +111,22 @@ impl GlobalStateCreator { /// Adds a function to generate global state at build-time. #[cfg(not(target_arch = "wasm32"))] - pub fn build_state_fn<S>( + pub fn build_state_fn<S, V>( mut self, - val: impl GlobalStateBuildUserFnType<S> + Clone + Send + Sync + 'static, + val: impl GlobalStateBuildUserFnType<S, V> + Clone + Send + Sync + 'static, ) -> Self where S: Serialize + DeserializeOwned + MakeRx, + V: Into<GeneratorResult<S>>, { self.build = Some(Box::new(move |locale| { let val = val.clone(); async move { - let user_state = val.call(locale).await?; + let user_state = val + .call(locale) + .await + .into() + .into_server_result("global_build_state", "GLOBAL_STATE".to_string())?; let template_state: TemplateState = user_state.into(); Ok(template_state) } @@ -126,17 +141,22 @@ impl GlobalStateCreator { /// Adds a function to generate global state at request-time. #[cfg(not(target_arch = "wasm32"))] - pub fn request_state_fn<S>( + pub fn request_state_fn<S, V>( mut self, - val: impl GlobalStateRequestUserFnType<S> + Clone + Send + Sync + 'static, + val: impl GlobalStateRequestUserFnType<S, V> + Clone + Send + Sync + 'static, ) -> Self where S: Serialize + DeserializeOwned + MakeRx, + V: Into<BlamedGeneratorResult<S>>, { self.request = Some(Box::new(move |locale, req| { let val = val.clone(); async move { - let user_state = val.call(locale, req).await?; + let user_state = val + .call(locale, req) + .await + .into() + .into_server_result("global_request_state", "GLOBAL_STATE".to_string())?; let template_state: TemplateState = user_state.into(); Ok(template_state) } @@ -151,12 +171,13 @@ impl GlobalStateCreator { /// Adds a function to amalgamate build-time and request-time global state. #[cfg(not(target_arch = "wasm32"))] - pub fn amalgamate_states_fn<S>( + pub fn amalgamate_states_fn<S, V>( mut self, - val: impl GlobalStateAmalgamationUserFnType<S> + Clone + Send + Sync + 'static, + val: impl GlobalStateAmalgamationUserFnType<S, V> + Clone + Send + Sync + 'static, ) -> Self where S: Serialize + DeserializeOwned + MakeRx + Send + Sync + 'static, + V: Into<BlamedGeneratorResult<S>>, { self.amalgamation = Some(Box::new( move |locale, build_state: TemplateState, request_state: TemplateState| { @@ -164,7 +185,7 @@ impl GlobalStateCreator { async move { // Amalgamation logic will only be called if both states are indeed defined let typed_build_state = build_state.change_type::<S>(); - let user_build_state = match typed_build_state.to_concrete() { + let user_build_state = match typed_build_state.into_concrete() { Ok(state) => state, Err(err) => panic!( "unrecoverable error in state amalgamation parameter derivation: {:#?}", @@ -172,7 +193,7 @@ impl GlobalStateCreator { ), }; let typed_request_state = request_state.change_type::<S>(); - let user_request_state = match typed_request_state.to_concrete() { + let user_request_state = match typed_request_state.into_concrete() { Ok(state) => state, Err(err) => panic!( "unrecoverable error in state amalgamation parameter derivation: {:#?}", @@ -181,7 +202,12 @@ impl GlobalStateCreator { }; let user_state = val .call(locale, user_build_state, user_request_state) - .await?; + .await + .into() + .into_server_result( + "global_amalgamate_states", + "GLOBAL_STATE".to_string(), + )?; let template_state: TemplateState = user_state.into(); Ok(template_state) } @@ -199,18 +225,7 @@ impl GlobalStateCreator { #[cfg(not(target_arch = "wasm32"))] pub async fn get_build_state(&self, locale: String) -> Result<TemplateState, ServerError> { if let Some(get_build_state) = &self.build { - let res = get_build_state.call(locale).await; - match res { - Ok(res) => Ok(res), - // Unlike template build state, there's no incremental generation here, so the - // client can't have caused an error - Err(err) => Err(ServerError::RenderFnFailed { - fn_name: "get_build_state".to_string(), - template_name: "GLOBAL_STATE".to_string(), - cause: ErrorCause::Server(None), - source: err, - }), - } + get_build_state.call(locale).await } else { Err(BuildError::TemplateFeatureNotEnabled { template_name: "GLOBAL_STATE".to_string(), @@ -227,16 +242,7 @@ impl GlobalStateCreator { req: Request, ) -> Result<TemplateState, ServerError> { if let Some(get_request_state) = &self.request { - let res = get_request_state.call(locale, req).await; - match res { - Ok(res) => Ok(res), - Err(GenericErrorWithCause { error, cause }) => Err(ServerError::RenderFnFailed { - fn_name: "get_request_state".to_string(), - template_name: "GLOBAL_STATE".to_string(), - cause, - source: error, - }), - } + get_request_state.call(locale, req).await } else { Err(BuildError::TemplateFeatureNotEnabled { template_name: "GLOBAL_STATE".to_string(), @@ -255,18 +261,9 @@ impl GlobalStateCreator { request_state: TemplateState, ) -> Result<TemplateState, ServerError> { if let Some(amalgamate_states) = &self.amalgamation { - let res = amalgamate_states + amalgamate_states .call(locale, build_state, request_state) - .await; - match res { - Ok(res) => Ok(res), - Err(GenericErrorWithCause { error, cause }) => Err(ServerError::RenderFnFailed { - fn_name: "amalgamate_states".to_string(), - template_name: "GLOBAL_STATE".to_string(), - cause, - source: error, - }), - } + .await } else { Err(BuildError::TemplateFeatureNotEnabled { template_name: "GLOBAL_STATE".to_string(), @@ -308,47 +305,39 @@ impl GlobalState { } /// The backend for the different types of global state. +#[derive(Debug)] pub enum GlobalStateType { /// The global state has been deserialized and loaded, and is ready for use. Loaded(Box<dyn AnyFreeze>), /// The global state is in string form from the server. Server(TemplateState), - /// There was no global state provided by the server. + /// The global state provided by the server was empty, indicating that this + /// app does not use global state. None, } impl GlobalStateType { /// Parses the global state into the given reactive type if possible. If the /// state from the server hasn't been parsed yet, this will return - /// `None`. + /// `None`. This will return an error if a type mismatch occurred. /// /// In other words, this will only return something if the global state has /// already been requested and loaded. - pub fn parse_active<R>(&self) -> Option<<R::Unrx as MakeRx>::Rx> + pub fn parse_active<S>(&self) -> Result<Option<S::Rx>, ClientError> where - R: Clone + AnyFreeze + MakeUnrx, - // We need this so that the compiler understands that the reactive version of the - // unreactive version of `R` has the same properties as `R` itself - <<R as MakeUnrx>::Unrx as MakeRx>::Rx: Clone + AnyFreeze + MakeUnrx, + S: MakeRx, + S::Rx: MakeUnrx<Unrx = S> + AnyFreeze + Clone, { match &self { // If there's an issue deserializing to this type, we'll fall back to the server - Self::Loaded(any) => any - .as_any() - .downcast_ref::<<R::Unrx as MakeRx>::Rx>() - .cloned(), - Self::Server(_) => None, - Self::None => None, - } - } -} -impl Freeze for GlobalStateType { - fn freeze(&self) -> String { - match &self { - Self::Loaded(state) => state.freeze(), - // There's no point in serializing state that was sent from the server, since we can - // easily get it again later (it can't possibly have been changed on the browser-side) - Self::Server(_) => "Server".to_string(), - Self::None => "None".to_string(), + Self::Loaded(any) => { + let rx = any + .as_any() + .downcast_ref::<S::Rx>() + .ok_or(ClientInvariantError::GlobalStateDowncast)? + .clone(); + Ok(Some(rx)) + } + Self::Server(_) | Self::None => Ok(None), } } } @@ -358,34 +347,32 @@ impl std::fmt::Debug for GlobalState { } } -// /// A representation of global state parsed into a specific type. -// pub enum ParsedGlobalState<R> { -// /// The global state has been deserialized and loaded, and is ready for -// use. Loaded(R), -// /// We couldn't parse to the desired reactive type. -// ParseError, -// /// The global state is in string form from the server. -// Server(String), -// /// There was no global state provided by the server. -// None, -// } - -/// A utility function for getting the global state that has already been built -/// at build-time. If there was none built, then this will return an empty -/// [`TemplateState`] (hence, a `StoreError::NotFound` is impossible from this -/// function). -#[cfg(not(target_arch = "wasm32"))] -pub async fn get_built_global_state( - immutable_store: &ImmutableStore, -) -> Result<TemplateState, ServerError> { - let res = immutable_store.read("static/global_state.json").await; - match res { - Ok(state) => { - let state = TemplateState::from_str(&state) - .map_err(|err| ServerError::InvalidPageState { source: err })?; - Ok(state) +/// Frozen global state. +#[cfg(target_arch = "wasm32")] +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum FrozenGlobalState { + /// There is state that should be instantiated. + Some(String), + /// The global state had not been modified from the engine-side. In this + /// case, we don't bother storing the frozen state, since it can be + /// trivially re-instantiated. + Server, + /// There was no global state. + None, + /// The frozen global state has already been used. This could be used to + /// ignore a global state in the frozen version of an app that does use + /// global state (as opposed to using `None` in such an app, which would + /// cause an invariant error), however thaw preferences exsit for exactly + /// this purpose. + Used, +} +#[cfg(target_arch = "wasm32")] +impl From<&GlobalStateType> for FrozenGlobalState { + fn from(val: &GlobalStateType) -> Self { + match val { + GlobalStateType::Loaded(state) => Self::Some(state.freeze()), + GlobalStateType::None => Self::None, + GlobalStateType::Server(_) => Self::Server, } - Err(StoreError::NotFound { .. }) => Ok(TemplateState::empty()), - Err(err) => Err(err.into()), } } diff --git a/packages/perseus/src/state/hsr.rs b/packages/perseus/src/state/hsr.rs deleted file mode 100644 index 0b0d5defdb..0000000000 --- a/packages/perseus/src/state/hsr.rs +++ /dev/null @@ -1,72 +0,0 @@ -use super::IdbFrozenStateStore; -use crate::template::RenderCtx; -use wasm_bindgen::JsValue; - -/// Freezes the app's state to IndexedDB to be accessed in future. This takes a -/// pre-determined frozen state to avoid *really* annoying lifetime errors. -pub(crate) async fn hsr_freeze(frozen_state: String) { - // We use a custom name so we don't interfere with any state freezing the user's - // doing independently - let idb_store = match IdbFrozenStateStore::new_with_name("perseus_hsr").await { - Ok(idb_store) => idb_store, - Err(err) => return log(&format!("IndexedDB setup error: {}.", err)), - }; - match idb_store.set(&frozen_state).await { - Ok(_) => log("State frozen."), - Err(err) => log(&format!("State freezing error: {}.", err)), - }; -} - -/// Thaws a previous state frozen in development. -// This will be run at the beginning of every template function, which means it gets executed on the -// server as well, so we have to Wasm-gate this -#[cfg(target_arch = "wasm32")] -pub(crate) async fn hsr_thaw(render_ctx: &RenderCtx) { - use super::{PageThawPrefs, ThawPrefs}; - - let idb_store = match IdbFrozenStateStore::new_with_name("perseus_hsr").await { - Ok(idb_store) => idb_store, - Err(err) => return log(&format!("IndexedDB setup error: {}.", err)), - }; - let frozen_state = match idb_store.get().await { - Ok(Some(frozen_state)) => frozen_state, - // If there's no frozen state available, we'll proceed as usual - Ok(None) => return, - Err(err) => return log(&format!("Frozen state acquisition error: {}.", err)), - }; - - // This is designed to override everything to restore the app to its previous - // state, so we should override everything This isn't problematic because - // the state will be frozen right before the reload and restored right after, so - // we literally can't miss anything (unless there's auto-typing tech involved!) - let thaw_prefs = ThawPrefs { - page: PageThawPrefs::IncludeAll, - global_prefer_frozen: true, - }; - // To be absolutely clear, this will NOT fail if the user has changed their data - // model, it will be triggered if the state is actually corrupted - // If that's the case, we'll log it and wait for the next freeze to override the - // invalid stuff If the user has updated their data model, the macros will - // fail with frozen state and switch to active or generated as necessary - // (meaning we lose the smallest amount of state possible!) - match render_ctx.thaw(&frozen_state, thaw_prefs) { - Ok(_) => log("State restored."), - Err(_) => log("Stored state corrupted, waiting for next code change to override."), - }; - - // We don't want this old state to persist if the user manually reloads (they'd - // be greeted with state that's probably out-of-date) - match idb_store.clear().await { - Ok(_) => (), - Err(err) => log(&format!("Stale state clearing error: {}.", err)), - } -} - -/// Thaws a previous state frozen in development. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) async fn hsr_thaw(_render_ctx: &RenderCtx) {} - -/// An internal function for logging data about HSR. -fn log(msg: &str) { - web_sys::console::log_1(&JsValue::from("[HSR]: ".to_string() + msg)); -} diff --git a/packages/perseus/src/state/live_reload.rs b/packages/perseus/src/state/live_reload.rs index 28cdf7e36e..79661b2934 100644 --- a/packages/perseus/src/state/live_reload.rs +++ b/packages/perseus/src/state/live_reload.rs @@ -95,8 +95,10 @@ fn log(msg: &str) { /// effect. /// /// # Panics -/// This will panic if it was impossible to reload (which would be caused by a -/// *very* old browser). +/// This will panic if it was impossible to reload (which could be caused by a +/// *very* old browser). Don't worry about this, because the panic handler has +/// probably already been fired long ago if that's the kind of environment +/// we're working in. pub(crate) fn force_reload() { web_sys::window() .unwrap() diff --git a/packages/perseus/src/state/mod.rs b/packages/perseus/src/state/mod.rs index 0729d97f93..674998f2b8 100644 --- a/packages/perseus/src/state/mod.rs +++ b/packages/perseus/src/state/mod.rs @@ -1,18 +1,26 @@ -mod freeze; +#[cfg(target_arch = "wasm32")] +mod freeze; // This has `FrozenApp` etc. mod global_state; -mod page_state_store; mod rx_result; mod rx_state; +mod state_generator_info; +mod state_store; #[cfg(target_arch = "wasm32")] mod suspense; +mod template_state; +// #[cfg(feature = "rx-collections")] +pub mod rx_collections; +#[cfg(target_arch = "wasm32")] pub use freeze::{FrozenApp, PageThawPrefs, ThawPrefs}; -#[cfg(not(target_arch = "wasm32"))] -pub use global_state::get_built_global_state; +#[cfg(target_arch = "wasm32")] +pub(crate) use global_state::FrozenGlobalState; pub use global_state::{GlobalState, GlobalStateCreator, GlobalStateType}; -pub use page_state_store::{PageStateStore, PssContains, PssEntry, PssState}; -pub use rx_result::{RxResult, RxResultIntermediate, RxResultRef, SerdeInfallible}; -pub use rx_state::{AnyFreeze, Freeze, MakeRx, MakeRxRef, MakeUnrx, RxRef, UnreactiveState}; +pub use rx_result::{RxResult, RxResultRx, SerdeInfallible}; +pub use rx_state::{AnyFreeze, Freeze, MakeRx, MakeUnrx, UnreactiveState}; +pub use state_generator_info::{BuildPaths, StateGeneratorInfo}; +pub use state_store::{PageStateStore, PssContains, PssEntry, PssState}; +pub use template_state::{TemplateState, TemplateStateWithType, UnknownStateType}; #[cfg(all(feature = "idb-freezing", target_arch = "wasm32"))] mod freeze_idb; @@ -29,8 +37,3 @@ pub(crate) use live_reload::connect_to_reload_server; pub(crate) use live_reload::force_reload; #[cfg(target_arch = "wasm32")] pub use suspense::{compute_nested_suspense, compute_suspense}; - -#[cfg(all(feature = "hsr", debug_assertions, target_arch = "wasm32"))] -mod hsr; -#[cfg(all(feature = "hsr", debug_assertions, target_arch = "wasm32"))] -pub(crate) use hsr::{hsr_freeze, hsr_thaw}; diff --git a/packages/perseus/src/state/rx_collections/mod.rs b/packages/perseus/src/state/rx_collections/mod.rs new file mode 100644 index 0000000000..688ecff19b --- /dev/null +++ b/packages/perseus/src/state/rx_collections/mod.rs @@ -0,0 +1,85 @@ +//! This module contains implementations of common Rust collections that work +//! well with Perseus' reactive state platform. So that this is as extensible as +//! possible, all the code in this module follows a general pattern, so that you +//! can easily create your own implementations as necessary! +//! +//! First, it is important to understand that each type has two versions: +//! `Collection` and `CollectionNested`. The former works with elements that +//! will be simply wrapped in `RcSignal`s, while the latter expects its elements +//! to implement `MakeRx` etc. This difference can be extremely useful for +//! users, because sometimes you'll want a vector of some reactive `struct`, +//! while other times you'll just want a vector of `String`s. +//! +//! Second, each reactive collection is a thin wrapper over the basic +//! collection. For example, [`RxVecNested`] is defined as `struct +//! RxVecNested<T>(Vec<T>)`, with several constraints on `T`. +//! +//! Third, each reactive collection has itself two types: `RxCollection` and +//! `RxCollectionRx`. The former is the base, unreactive collections, while the +//! latter is fully reactive. This is just like how the +//! `#[derive(ReactiveState)]` macro creates an alias type `MyStateRx` +//! for some state `MyState`. Note that these `struct`s should have the same +//! type bounds on `T`. +//! +//! Fourth, the unreactive types will have to implement `Serialize` and +//! `Deserialize`, from Serde. You can get this working by *omitting* the +//! `Serialize + DeserializeOwned` bounds on `T` in the unreactive type +//! definition, and by then letting the derive macros from Serde fill +//! them in automatically. Note the use of `DeserializeOwned` in type bounds, +//! which avoids lifetime concerns and HRTBs. +//! +//! Finally, every file in this module follows the same code pattern, allowing +//! maximal extensibility and self-documentation: +//! +//! ``` +//! // --- Type definitions --- +//! // ... +//! // --- Reactivity implementations --- +//! // ... +//! // --- Dereferencing --- +//! // ... +//! // --- Conversion implementation --- +//! // ... +//! // --- Freezing implementation --- +//! // ... +//! ``` +//! +//! The *type definitions* section contains the actual definitions of the +//! reactive and unreactive collection types, while the *reactivity +//! implementations* section contains the implementations of `MakeRx` for the +//! unreactive type, and `MakeUnrx` for the reactive type. +//! +//! The *dereferencing* section contains implementations that allow users to use +//! the methods of the underlying collection on these wrapper types. For +//! example, by implementing `Deref` with a target of `Vec<T::Rx>` for +//! `RxVecNestedRx<T>`, users can take that reactive type and call methods like +//! `.iter()` on it. +//! +//! The *conversion implementation* section implements `From` for the unreactive +//! type, allowing users to easily create the base unreactive type from the type +//! it wraps (e.g. `RxVecNested<T>` from a `Vec<T>`). This is primarily used in +//! functions like `get_build_state`, where users can create the normal Rust +//! collection, and just add `.into()` to integrate it with the Perseus +//! state platform. Note that we **do not** implement `From` for the reactive +//! version, as this will never be of use to users (reactive types should only +//! ever come out of Perseus itself, so they can be registered in the state +//! store, etc.). +//! +//! Finally, the *freezing implementation* section implements `Freeze` for the +//! reactive type, which allows Perseus to turn it into a `String` easily for +//! state freezing. Thawing is handled automatically and internally. +//! +//! *A brief note on `RxResult<T, E>`: the reactive result type does not follow +//! the patterns described above, and it defined in a separate module, because +//! it does not have a non-nested equivalent. This is because such a thing would +//! have no point, as there are no 'fields' in a `Result` (or any other +//! `enum`, for that matter) itself. If a non-nested version is required, one +//! should simply use `std::result::Result`. The same goes for `Option<T>`, +//! although there is presently no defined reactive container for this.* +//! +//! **Note:** as a user, you will still have to use `#[rx(nested)]` over any +//! reactive types you use! + +mod rx_vec_nested; + +pub use rx_vec_nested::{RxVecNested, RxVecNestedRx}; diff --git a/packages/perseus/src/state/rx_collections/rx_vec_nested.rs b/packages/perseus/src/state/rx_collections/rx_vec_nested.rs new file mode 100644 index 0000000000..7abc6b79cd --- /dev/null +++ b/packages/perseus/src/state/rx_collections/rx_vec_nested.rs @@ -0,0 +1,101 @@ +use crate::state::{Freeze, MakeRx, MakeUnrx}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::ops::Deref; +#[cfg(target_arch = "wasm32")] +use sycamore::prelude::Scope; + +/// A reactive version of [`Vec`] that uses nested reactivity on its elements. +/// That means the type inside the vector must implement [`MakeRx`] (usually +/// derived with the `ReactiveState` macro). If you want to store simple types +/// inside the vector, without nested reactivity (e.g. `String`s), you should +/// use [`RxVec`]. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct RxVecNested<T>(Vec<T>) +where + // We get the `Deserialize` derive macro working by tricking Serde by not + // including the actual bounds here + T: MakeRx + 'static, + T::Rx: MakeUnrx<Unrx = T> + Freeze + Clone; +/// The reactive version of [`RxVecNested`]. +#[derive(Clone, Debug)] +pub struct RxVecNestedRx<T>(Vec<T::Rx>) +where + T: MakeRx + Serialize + DeserializeOwned + 'static, + T::Rx: MakeUnrx<Unrx = T> + Freeze + Clone; + +// --- Reactivity implementations --- +impl<T> MakeRx for RxVecNested<T> +where + T: MakeRx + Serialize + DeserializeOwned + 'static, + T::Rx: MakeUnrx<Unrx = T> + Freeze + Clone, +{ + type Rx = RxVecNestedRx<T>; + + fn make_rx(self) -> Self::Rx { + RxVecNestedRx(self.0.into_iter().map(|x| x.make_rx()).collect()) + } +} +impl<T> MakeUnrx for RxVecNestedRx<T> +where + T: MakeRx + Serialize + DeserializeOwned + 'static, + T::Rx: MakeUnrx<Unrx = T> + Freeze + Clone, +{ + type Unrx = RxVecNested<T>; + + fn make_unrx(self) -> Self::Unrx { + RxVecNested(self.0.into_iter().map(|x| x.make_unrx()).collect()) + } + + #[cfg(target_arch = "wasm32")] + fn compute_suspense(&self, cx: Scope) { + for elem in self.0.iter() { + elem.compute_suspense(cx); + } + } +} +// --- Dereferencing --- +impl<T> Deref for RxVecNested<T> +where + T: MakeRx + Serialize + DeserializeOwned + 'static, + T::Rx: MakeUnrx<Unrx = T> + Freeze + Clone, +{ + type Target = Vec<T>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl<T> Deref for RxVecNestedRx<T> +where + T: MakeRx + Serialize + DeserializeOwned + 'static, + T::Rx: MakeUnrx<Unrx = T> + Freeze + Clone, +{ + type Target = Vec<T::Rx>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} +// --- Conversion implementation --- +impl<T> From<Vec<T>> for RxVecNested<T> +where + T: MakeRx + Serialize + DeserializeOwned + 'static, + T::Rx: MakeUnrx<Unrx = T> + Freeze + Clone, +{ + fn from(value: Vec<T>) -> Self { + Self(value) + } +} + +// --- Freezing implementation --- +impl<T> Freeze for RxVecNestedRx<T> +where + T: MakeRx + Serialize + DeserializeOwned + 'static, + T::Rx: MakeUnrx<Unrx = T> + Freeze + Clone, +{ + fn freeze(&self) -> String { + let unrx = Self(self.0.clone()).make_unrx(); + // This should never panic, because we're dealing with a vector + serde_json::to_string(&unrx).unwrap() + } +} diff --git a/packages/perseus/src/state/rx_result.rs b/packages/perseus/src/state/rx_result.rs index f8c74ec7df..392059947c 100644 --- a/packages/perseus/src/state/rx_result.rs +++ b/packages/perseus/src/state/rx_result.rs @@ -1,7 +1,9 @@ -use super::{Freeze, MakeRx, MakeRxRef, MakeUnrx, RxRef}; +use super::{Freeze, MakeRx, MakeUnrx}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::ops::Deref; -use sycamore::prelude::{create_rc_signal, create_ref, RcSignal, Scope}; +#[cfg(target_arch = "wasm32")] +use sycamore::prelude::Scope; +use sycamore::prelude::{create_rc_signal, RcSignal}; /// A wrapper for fallible reactive state. /// @@ -25,41 +27,41 @@ use sycamore::prelude::{create_rc_signal, create_ref, RcSignal, Scope}; /// /// If you want non-nested, fallible, suspended state, you can simply use /// `Result<T, E>` from the standard library. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct RxResult<T, E>(Result<T, E>) where T: MakeRx + 'static, /* Serialize + DeserializeOwned are handled automatically by the derive * macro on both `T` and `E` */ - <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + MakeRxRef + Clone + 'static, + <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + Clone + 'static, E: Clone + 'static; impl<T, E> MakeRx for RxResult<T, E> where T: MakeRx + Serialize + DeserializeOwned + 'static, - <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + MakeRxRef + Clone + 'static, + <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + Clone + 'static, E: Serialize + DeserializeOwned + Clone + 'static, { - type Rx = RxResultIntermediate<T, E>; + type Rx = RxResultRx<T, E>; fn make_rx(self) -> Self::Rx { match self.0 { - Ok(state) => RxResultIntermediate(create_rc_signal(Ok(state.make_rx()))), - Err(err) => RxResultIntermediate(create_rc_signal(Err(err))), + Ok(state) => RxResultRx(create_rc_signal(Ok(state.make_rx()))), + Err(err) => RxResultRx(create_rc_signal(Err(err))), } } } /// The intermediate reactive type for [`RxResult`]. You shouldn't need to /// interface with this manually. -#[derive(Clone)] -pub struct RxResultIntermediate<T, E>(RcSignal<Result<T::Rx, E>>) +#[derive(Clone, Debug)] +pub struct RxResultRx<T, E>(RcSignal<Result<T::Rx, E>>) where T: MakeRx + Serialize + DeserializeOwned + 'static, - <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + MakeRxRef + Clone + 'static, + <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + Clone + 'static, E: Serialize + DeserializeOwned + Clone + 'static; -impl<T, E> MakeUnrx for RxResultIntermediate<T, E> +impl<T, E> MakeUnrx for RxResultRx<T, E> where T: MakeRx + Serialize + DeserializeOwned + 'static, - <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + MakeRxRef + Clone + 'static, + <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + Clone + 'static, E: Serialize + DeserializeOwned + Clone + 'static, { type Unrx = RxResult<T, E>; @@ -77,10 +79,10 @@ where #[cfg(target_arch = "wasm32")] fn compute_suspense<'a>(&self, _cx: Scope<'a>) {} } -impl<T, E> Freeze for RxResultIntermediate<T, E> +impl<T, E> Freeze for RxResultRx<T, E> where T: MakeRx + Serialize + DeserializeOwned + 'static, - <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + MakeRxRef + Clone + 'static, + <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + Clone + 'static, E: Serialize + DeserializeOwned + Clone + 'static, { fn freeze(&self) -> String { @@ -89,47 +91,13 @@ where serde_json::to_string(&unrx).unwrap() } } -impl<T, E> MakeRxRef for RxResultIntermediate<T, E> -where - T: MakeRx + Serialize + DeserializeOwned + 'static, - <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + MakeRxRef + Clone + 'static, - E: Serialize + DeserializeOwned + Clone + 'static, -{ - type RxRef<'rx> = RxResultRef<'rx, T, E>; // where <<T as MakeRx>::Rx as MakeRxRef>::RxRef<'rx>: 'rx; - - fn to_ref_struct<'rx>(self, cx: Scope<'rx>) -> Self::RxRef<'rx> { - RxResultRef(create_ref(cx, self.0)) - } -} - -/// The final reference reactive type for [`RxResult`]. This is what you'll get -/// passed to suspense handlers that deal with a field wrapper in [`RxResult`]. -/// -/// Note that the underlying nested type will not be in its final reference -/// form, it will be in its intermediate form (otherwise dependency tracking is -/// impossible), although, due to the high-level scoped wrapping, ergonomics are -/// preserved. -#[derive(Clone)] -pub struct RxResultRef<'rx, T, E>(&'rx RcSignal<Result<T::Rx, E>>) -where - T: MakeRx + Serialize + DeserializeOwned + 'static, - <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + MakeRxRef + Clone + 'static, - E: Serialize + DeserializeOwned + Clone + 'static; -impl<'rx, T, E> RxRef for RxResultRef<'rx, T, E> -where - T: MakeRx + Serialize + DeserializeOwned + 'static, - <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + MakeRxRef + Clone + 'static, - E: Serialize + DeserializeOwned + Clone + 'static, -{ - type RxNonRef = T::Rx; -} // We can implement all the `Signal` etc. methods by simply implementing the // appropriate dereferencing -impl<T, E> Deref for RxResultIntermediate<T, E> +impl<T, E> Deref for RxResultRx<T, E> where T: MakeRx + Serialize + DeserializeOwned + 'static, - <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + MakeRxRef + Clone + 'static, + <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + Clone + 'static, E: Serialize + DeserializeOwned + Clone + 'static, { type Target = RcSignal<Result<T::Rx, E>>; @@ -138,25 +106,13 @@ where &self.0 } } -impl<'rx, T, E> Deref for RxResultRef<'rx, T, E> -where - T: MakeRx + Serialize + DeserializeOwned + 'static, - <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + MakeRxRef + Clone + 'static, - E: Serialize + DeserializeOwned + Clone + 'static, -{ - type Target = &'rx RcSignal<Result<T::Rx, E>>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} // We also want a usual `Result` to be able to be turned into `RxResult` for // convenience impl<T, E> From<Result<T, E>> for RxResult<T, E> where T: MakeRx + Serialize + DeserializeOwned + 'static, - <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + MakeRxRef + Clone + 'static, + <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + Clone + 'static, E: Serialize + DeserializeOwned + Clone + 'static, { fn from(val: Result<T, E>) -> Self { diff --git a/packages/perseus/src/state/rx_state.rs b/packages/perseus/src/state/rx_state.rs index bb3debcfbb..2654bd0834 100644 --- a/packages/perseus/src/state/rx_state.rs +++ b/packages/perseus/src/state/rx_state.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::any::Any; +#[cfg(target_arch = "wasm32")] use sycamore::prelude::Scope; /// A trait for `struct`s that can be made reactive. Typically, this will be @@ -19,6 +20,10 @@ pub trait MakeRx { /// opposite of `MakeRx`, and is intended particularly for state freezing. Like /// `MakeRx`, this will usually be derived automatically with the `#[make_rx]` /// macro, but you can also implement it manually. +/// +/// The types that implement this are typically referred to as the *intermediate +/// state* types, as they are rendered far more ergonomic to use by being put +/// through Sycamore's `create_ref()` function. pub trait MakeUnrx { /// The type of the unreactive version that we'll convert to. type Unrx: Serialize + for<'de> Deserialize<'de> + MakeRx; @@ -54,27 +59,6 @@ pub trait MakeUnrx { fn compute_suspense<'a>(&self, cx: Scope<'a>); } -/// A trait for reactive `struct`s that can be made to use `&'a Signal`s -/// rather than `RcSignal`s, when provided with a Sycamore reactive scope. -/// This is necessary for reaping the benefits of the ergonomics of Sycamore's -/// v2 reactive primitives. -pub trait MakeRxRef { - /// The type of the reactive `struct` using `&'a Signal`s (into which - /// the type implementing this trait can be converted). - type RxRef<'a>; - /// Convert this into a version using `&'a Signal`s using `create_ref()`. - fn to_ref_struct<'a>(self, cx: Scope<'a>) -> Self::RxRef<'a>; -} - -/// A trait for `struct`s that are both reactive *and* using `&'a Signal`s -/// to store their underlying data. This exists solely to link such types to -/// their intermediate, `RcSignal`, equivalents. -pub trait RxRef { - /// The linked intermediate type using `RcSignal`s. Note that this is - /// itself reactive, just not very ergonomic. - type RxNonRef: MakeUnrx; -} - /// A trait for reactive `struct`s that can be made unreactive and serialized to /// a `String`. `struct`s that implement this should implement `MakeUnrx` for /// simplicity, but they technically don't have to (they always do in Perseus @@ -96,6 +80,12 @@ impl<T: Any + Freeze> AnyFreeze for T { self } } +impl std::fmt::Debug for (dyn AnyFreeze + 'static) { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // See Rust std/core/any.rs:213 + f.debug_struct("AnyFreeze").finish_non_exhaustive() + } +} /// A marker trait for types that you want to be able to use with the Perseus /// state platform, without using `#[make_rx]`. If you want to use unreactive @@ -115,7 +105,7 @@ pub trait UnreactiveState {} /// This wrapper will automatically implement all the necessary `trait`s to /// interface with Perseus' reactive state platform, along with `Serialize` and /// `Deserialize` (provided the underlying type also implements the latter two). -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct UnreactiveStateWrapper< T: Serialize + for<'de> Deserialize<'de> + UnreactiveState + Clone, >(pub T); diff --git a/packages/perseus/src/state/state_generator_info.rs b/packages/perseus/src/state/state_generator_info.rs new file mode 100644 index 0000000000..79b04ebc7a --- /dev/null +++ b/packages/perseus/src/state/state_generator_info.rs @@ -0,0 +1,69 @@ +use super::{TemplateState, TemplateStateWithType}; +use serde::{de::DeserializeOwned, Serialize}; + +/// The output of the build seed system, which should be generated by a user +/// function for each template. +#[derive(Debug)] +pub struct BuildPaths { + /// The paths to render underneath this template, without the template name + /// or leading forward slashes. + pub paths: Vec<String>, + /// Any additional state, of an arbitrary type, to be passed to all future + /// state generation. This can be used to avoid unnecessary duplicate + /// filesystem reads, or the like. + /// + /// The exact type information from this is deliberately discarded. + pub extra: TemplateState, +} + +/// The information any function that generates state will be provided. +/// +/// This must be able to be shared safely between threads. +#[derive(Clone, Debug)] +pub struct StateGeneratorInfo<B: Serialize + DeserializeOwned + Send + Sync> { + /// The path it is generating for, not including the template name or + /// locale. + /// + /// **Warning:** previous versions of Perseus used to prefix this with the + /// template name, and this is no longer done, for convenience of + /// handling. + pub path: String, + /// The locale it is generating for. + pub locale: String, + /// Any extra data from the template's build seed. + pub(crate) extra: TemplateStateWithType<B>, +} +impl<B: Serialize + DeserializeOwned + Send + Sync + 'static> StateGeneratorInfo<B> { + /// Transform the underlying [`TemplateStateWithType`] into one with a + /// different type. Once this is done, `.to_concrete()` can be used to + /// get this type out of the container. + #[cfg(not(target_arch = "wasm32"))] // Just to silence clippy (if you need to remove this, do) + pub(crate) fn change_type<U: Serialize + DeserializeOwned + Send + Sync>( + self, + ) -> StateGeneratorInfo<U> { + StateGeneratorInfo { + path: self.path, + locale: self.locale, + extra: self.extra.change_type(), + } + } + /// Get the extra build state as an owned type. + /// + /// # Panics + /// Hypothetically, if there were a failure in the Perseus core such that + /// your extra build state ended up being malformed, this would panic. + /// However, this should never happen, as there are multiplr layers of + /// checks before this that should catch such an event. If this panics, + /// and if keeps panicking after `perseus clean`, please report it as a + /// bug (assuming all your types are correct). + pub fn get_extra(&self) -> B { + match B::deserialize(&self.extra.state) { + Ok(extra) => extra, + // This should never happen... + Err(err) => panic!( + "unrecoverable extra build state extraction failure: {:#?}", + err + ), + } + } +} diff --git a/packages/perseus/src/state/page_state_store.rs b/packages/perseus/src/state/state_store.rs similarity index 56% rename from packages/perseus/src/state/page_state_store.rs rename to packages/perseus/src/state/state_store.rs index b9c4ba889c..1bfec89859 100644 --- a/packages/perseus/src/state/page_state_store.rs +++ b/packages/perseus/src/state/state_store.rs @@ -1,5 +1,9 @@ +use crate::errors::{ClientError, ClientInvariantError}; use crate::page_data::PageDataPartial; +use crate::path::*; use crate::state::AnyFreeze; +#[cfg(target_arch = "wasm32")] +use serde_json::Value; use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; @@ -11,10 +15,7 @@ use std::rc::Rc; /// with this entirely independently of Perseus' state interface, though this /// isn't recommended. /// -/// Note that the same pages in different locales will have different entries -/// here. If you need to store state for a page across locales, you should use -/// the global state system instead. For apps not using i18n, the page URL will -/// not include any locale. +/// Paths in this store will have their locales prepended if the app uses i18n. // WARNING: Never allow users to manually modify the internal maps/orderings of this, // or the eviction protocols will become very confused! #[derive(Clone)] @@ -27,18 +28,25 @@ pub struct PageStateStore { /// This also stores the head string for each page, which means we don't /// need to re-request old pages from the server whatsoever, minimizing /// requests. + /// + /// Note that this stores both pages and capsules. // Technically, this should be `Any + Clone`, but that's not possible without something like // `dyn_clone`, and we don't need it because we can restrict on the methods instead! - map: Rc<RefCell<HashMap<String, PssEntry>>>, + map: Rc<RefCell<HashMap<PathMaybeWithLocale, PssEntry>>>, /// The order in which pages were submitted to the store. This is used to /// evict the state of old pages to prevent Perseus sites from becoming /// massive in the browser's memory and slowing the user's browser down. - order: Rc<RefCell<Vec<String>>>, + /// + /// This will *not* store capsules. + order: Rc<RefCell<Vec<PathMaybeWithLocale>>>, /// The maximum size of the store before pages are evicted, specified in /// terms of a number of pages. Note that this pays no attention to the /// size in memory of individual pages (which should be dropped manually /// if this is a concern). /// + /// This will only apply to the number of pages stored! If one page depends + /// on 300 capsules, they will be completely ignored! + /// /// Note: whatever you set here will impact HSR. max_size: usize, /// A list of pages that will be kept in the store no matter what. This can @@ -47,15 +55,23 @@ pub struct PageStateStore { /// use-cases for this would be better fulfilled by using global state, and /// this API is *highly* likely to be misused! If at all possible, use /// global state! - keep_list: Rc<RefCell<Vec<String>>>, - /// A list of pages whose data have been manually preloaded to minimize - /// future network requests. This list is intended for pages that are to - /// be globally preloaded; any pages that should only be preloaded for a - /// specific route should be placed in `route_preloaded` instead. - preloaded: Rc<RefCell<HashMap<String, PageDataPartial>>>, - /// Pages that have been preloaded for the current route, which should be - /// cleared on a route change. - route_preloaded: Rc<RefCell<HashMap<String, PageDataPartial>>>, + // TODO Can widgets be specified here? + keep_list: Rc<RefCell<Vec<PathMaybeWithLocale>>>, + /// A list of pages/widgets whose data have been manually preloaded to + /// minimize future network requests. This list is intended for pages + /// that are to be globally preloaded; any pages that should only be + /// preloaded for a specific route should be placed in `route_preloaded` + /// instead. + /// + /// Note that this is used to store the 'preloaded' widgets from the server + /// in initial loads, before the actual `Widget` components take them up + /// for rendering. + preloaded: Rc<RefCell<HashMap<PathMaybeWithLocale, PageDataPartial>>>, + /// Pages/widgets that have been preloaded for the current route, which + /// should be cleared on a route change. This is broken out to allow + /// future preloading based on heuristics for a given page, which should + /// be dumped if none of the pages are actually used. + route_preloaded: Rc<RefCell<HashMap<PathMaybeWithLocale, PageDataPartial>>>, } impl PageStateStore { /// Creates a new, empty page state store with the given maximum size. After @@ -78,7 +94,7 @@ impl PageStateStore { /// returned. /// /// This will NOT return any document metadata, if any exists. - pub fn get_state<T: AnyFreeze + Clone>(&self, url: &str) -> Option<T> { + pub fn get_state<T: AnyFreeze + Clone>(&self, url: &PathMaybeWithLocale) -> Option<T> { let map = self.map.borrow(); match map.get(url) { Some(entry) => { @@ -94,7 +110,7 @@ impl PageStateStore { } } /// Gets the document metadata registered for a URL, if it exists. - pub fn get_head(&self, url: &str) -> Option<String> { + pub fn get_head(&self, url: &PathMaybeWithLocale) -> Option<String> { let map = self.map.borrow(); match map.get(url) { Some(entry) => entry.head.as_ref().map(|v| v.to_string()), @@ -106,52 +122,52 @@ impl PageStateStore { /// be overridden, but any document metadata will be preserved. /// /// This will be added to the end of the `order` property, and any previous - /// entries of it in that list will be removed. + /// entries of it in that list will be removed, unless it is specified to + /// be a widget. /// /// If there's already an entry for the given URL that has been marked as - /// not accepting state, this will return `false`, and the entry will - /// not be added. This *must* be handled for correctness. - #[must_use] - pub fn add_state<T: AnyFreeze + Clone>(&self, url: &str, val: T) -> bool { + /// not accepting state, this will return an error, and the entry will + /// not be added. When this is called for HSR purposes, this should be taken + /// with a grain of salt, as documented on `.set_state()` for [`PssEntry`]. + pub fn add_state<T: AnyFreeze + Clone>( + &self, + url: &PathMaybeWithLocale, + val: T, + is_widget: bool, + ) -> Result<(), ClientError> { let mut map = self.map.borrow_mut(); // We want to modify any existing entries to avoid wiping out document metadata if let Some(entry) = map.get_mut(url) { - if !entry.set_state(Box::new(val)) { - return false; - } + entry.set_state(Box::new(val))? } else { let mut new_entry = PssEntry::default(); - if !new_entry.set_state(Box::new(val)) { - return false; - } - map.insert(url.to_string(), new_entry); + new_entry.set_state(Box::new(val))?; + map.insert(url.clone(), new_entry); } let mut order = self.order.borrow_mut(); // If we haven't been told to keep this page, enter it in the order list so it - // can be evicted later - if !self.keep_list.borrow().iter().any(|x| x == url) { + // can be evicted later (unless it's a widget) + if !self.keep_list.borrow().iter().any(|x| x == url) && !is_widget { // Get rid of any previous mentions of this page in the order list order.retain(|stored_url| stored_url != url); - order.push(url.to_string()); + order.push(url.clone()); // If we've used up the maximum size yet, we should get rid of the oldest pages - if order.len() > self.max_size { - // Because this is called on every addition, we can safely assume that it's only - // one over - let old_url = order.remove(0); - map.remove(&old_url); // This will only occur for pages that - // aren't in the keep list, since those - // don't even appear in `order` - } + drop(order); + drop(map); + self.evict_page_if_needed(); } - // If we got to here, then there were no issues with not accepting state - true + + Ok(()) } /// Adds document metadata to the entry in the store for the given URL, /// creating it if it doesn't exist. /// /// This will be added to the end of the `order` property, and any previous /// entries of it in that list will be removed. - pub fn add_head(&self, url: &str, head: String) { + /// + /// This will accept widgets adding empty heads, since they do still need + /// to be registered. + pub fn add_head(&self, url: &PathMaybeWithLocale, head: String, is_widget: bool) { let mut map = self.map.borrow_mut(); // We want to modify any existing entries to avoid wiping out state if let Some(entry) = map.get_mut(url) { @@ -159,30 +175,25 @@ impl PageStateStore { } else { let mut new_entry = PssEntry::default(); new_entry.set_head(head); - map.insert(url.to_string(), new_entry); + map.insert(url.clone(), new_entry); } let mut order = self.order.borrow_mut(); // If we haven't been told to keep this page, enter it in the order list so it - // can be evicted later - if !self.keep_list.borrow().iter().any(|x| x == url) { + // can be evicted later (unless it's a widget) + if !self.keep_list.borrow().iter().any(|x| x == url) && !is_widget { // Get rid of any previous mentions of this page in the order list order.retain(|stored_url| stored_url != url); - order.push(url.to_string()); + order.push(url.clone()); // If we've used up the maximum size yet, we should get rid of the oldest pages - if order.len() > self.max_size { - // Because this is called on every addition, we can safely assume that it's only - // one over - let old_url = order.remove(0); - map.remove(&old_url); // This will only occur for pages that - // aren't in the keep list, since those - // don't even appear in `order` - } + drop(order); + drop(map); + self.evict_page_if_needed(); } } /// Sets the given entry as not being able to take any state. Any future /// attempt to register state for it will lead to silent failures and/or /// panics. - pub fn set_state_never(&self, url: &str) { + pub fn set_state_never(&self, url: &PathMaybeWithLocale, is_widget: bool) { let mut map = self.map.borrow_mut(); // If there's no entry for this URl yet, we'll create it if let Some(entry) = map.get_mut(url) { @@ -190,11 +201,23 @@ impl PageStateStore { } else { let mut new_entry = PssEntry::default(); new_entry.set_state_never(); - map.insert(url.to_string(), new_entry); + map.insert(url.clone(), new_entry); + } + let mut order = self.order.borrow_mut(); + // If we haven't been told to keep this page, enter it in the order list so it + // can be evicted later (unless it's a widget) + if !self.keep_list.borrow().iter().any(|x| x == url) && !is_widget { + // Get rid of any previous mentions of this page in the order list + order.retain(|stored_url| stored_url != url); + order.push(url.clone()); + // If we've used up the maximum size yet, we should get rid of the oldest pages + drop(order); + drop(map); + self.evict_page_if_needed(); } } /// Checks if the state contains an entry for the given URL. - pub fn contains(&self, url: &str) -> PssContains { + pub fn contains(&self, url: &PathMaybeWithLocale) -> PssContains { let map = self.map.borrow(); let contains = match map.get(url) { Some(entry) => match entry.state { @@ -229,30 +252,72 @@ impl PageStateStore { _ => contains, } } - /// Preloads the given URL from the server and adds it to the PSS. + /// Declares that a certain page/widget depends on a certain widget. This + /// will added as a bidirectional relation that can be used to control + /// when the widget will be evicted from the state store (which should + /// only happen after all the pages using it have also been evicted). + /// Failure to declare a widget here is not a *critical* error, but it + /// will lead to a seriously suboptimal user experience. + /// + /// # Panics + /// This function will panic if the given page and widget paths are not + /// already registered in the state store. + #[cfg(target_arch = "wasm32")] + pub(crate) fn declare_dependency( + &self, + widget_path: &PathMaybeWithLocale, + caller_path: &PathMaybeWithLocale, + ) { + let mut map = self.map.borrow_mut(); + { + let caller = map.get_mut(caller_path).expect("page/widget that was part of dependency declaration was not present in the state store"); + caller.add_dependency(widget_path.clone()); + } + + { + let widget = map.get_mut(widget_path).expect( + "widget that was part of dependency declaration was not present in the state store", + ); + widget.add_dependent(caller_path.clone()); + } + } + /// Preloads the given URL from the server and adds it to the PSS. This + /// expects a path that does *not* contain the present locale, as the + /// locale is provided separately. The two are concatenated + /// appropriately for locale-specific preloading in apps that use it. /// /// This function has no effect on the server-side. /// - /// Note that this should generally be called through `RenderCtx`, to avoid + /// Note that this should generally be called through `Reactor`, to avoid /// having to manually collect the required arguments. // Note that this function bears striking resemblance to the subsequent load system! #[cfg(target_arch = "wasm32")] pub(crate) async fn preload( &self, - path: &str, + path: &PathWithoutLocale, locale: &str, template_path: &str, was_incremental_match: bool, is_route_preload: bool, + // This changes a few flag types, like `AssetType` + is_widget: bool, ) -> Result<(), crate::errors::ClientError> { use crate::{ - errors::FetchError, + errors::{AssetType, FetchError}, utils::{fetch, get_path_prefix_client}, }; + let asset_ty = if is_widget { + AssetType::Widget + } else { + AssetType::Preload + }; + + let full_path = PathMaybeWithLocale::new(path, locale); + // If we already have the page loaded fully in the PSS, abort immediately if let PssContains::All | PssContains::HeadNoState | PssContains::Preloaded = - self.contains(path) + self.contains(&full_path) { return Ok(()); } @@ -264,19 +329,18 @@ impl PageStateStore { // true => "index".to_string(), // false => path.to_string(), // }; - let path_norm = path.to_string(); // Get the static page data (head and state) let asset_url = format!( - "{}/.perseus/page/{}/{}.json?template_name={}&was_incremental_match={}", + "{}/.perseus/page/{}/{}.json?entity_name={}&was_incremental_match={}", get_path_prefix_client(), locale, - path_norm, + **path, template_path, was_incremental_match ); // If this doesn't exist, then it's a 404 (we went here by explicit instruction, // but it may be an unservable ISR page or the like) - let page_data_str = fetch(&asset_url).await?; + let page_data_str = fetch(&asset_url, asset_ty).await?; match page_data_str { Some(page_data_str) => { // All good, deserialize the page data @@ -285,6 +349,7 @@ impl PageStateStore { FetchError::SerFailed { url: path.to_string(), source: err.into(), + ty: asset_ty, } })?; let mut preloaded = if is_route_preload { @@ -292,22 +357,38 @@ impl PageStateStore { } else { self.route_preloaded.borrow_mut() }; - preloaded.insert(path.to_string(), page_data); + preloaded.insert(full_path, page_data); Ok(()) } - None => Err(FetchError::NotFound { + None => Err(FetchError::PreloadNotFound { url: path.to_string(), + ty: asset_ty, } .into()), } } + /// Adds the given widget to the preload list so it can be later accessed + /// during the initial load render. This is not used for widgets in + /// subsequently loaded pages, which are fetched separately. + #[cfg(target_arch = "wasm32")] + pub(crate) fn add_initial_widget(&self, url: PathMaybeWithLocale, state: Value) { + let mut preloaded = self.preloaded.borrow_mut(); + // Widgets never have heads + preloaded.insert( + url, + PageDataPartial { + state, + head: String::new(), + }, + ); + } /// Gets a preloaded page. This will search both the globally and /// route-specifically preloaded pages. /// /// Note that this will delete the preloaded page from the preload cache, /// since it's expected to be parsed and rendered immediately. It should /// also have its head entered in the PSS. - pub fn get_preloaded(&self, url: &str) -> Option<PageDataPartial> { + pub fn get_preloaded(&self, url: &PathMaybeWithLocale) -> Option<PageDataPartial> { let mut preloaded = self.preloaded.borrow_mut(); let mut route_preloaded = self.route_preloaded.borrow_mut(); if let Some(page_data) = preloaded.remove(url) { @@ -319,9 +400,9 @@ impl PageStateStore { /// Clears all the routes that were preloaded for the last route, keeping /// only those listed (this should be used to make sure we don't have to /// double-preload things). - pub fn cycle_route_preloaded(&self, keep_urls: &[&str]) { + pub fn cycle_route_preloaded(&self, keep_urls: &[&PathMaybeWithLocale]) { let mut preloaded = self.route_preloaded.borrow_mut(); - preloaded.retain(|url, _| keep_urls.iter().any(|keep_url| keep_url == url)); + preloaded.retain(|url, _| keep_urls.iter().any(|keep_url| *keep_url == url)); } /// Forces the store to keep a certain page. This will prevent it from being /// evicted from the store, regardless of how many other pages are @@ -330,13 +411,13 @@ impl PageStateStore { /// Warning: in the *vast* majority of cases, your use-case for this will be /// far better served by the global state system! (If you use this with /// mutable state, you are quite likely to shoot yourself in the foot.) - pub fn force_keep(&self, url: &str) { + pub fn force_keep(&self, url: &PathMaybeWithLocale) { let mut order = self.order.borrow_mut(); // Get rid of any previous mentions of this page in the order list (which will // prevent this page from ever being evicted) order.retain(|stored_url| stored_url != url); let mut keep_list = self.keep_list.borrow_mut(); - keep_list.push(url.to_string()); + keep_list.push(url.clone()); } /// Forcibly removes a page from the store. Generally, you should never need /// to use this function, but it's provided for completeness. This could @@ -345,12 +426,50 @@ impl PageStateStore { /// not work (since it relies on the state freezing system). /// /// This returns the page's state, if it was found. - pub fn force_remove(&self, url: &str) -> Option<PssEntry> { + /// + /// Note: this will safely preserve the invariants of the store (as opposed + /// to manual removal). + pub fn force_remove(&self, url: &PathMaybeWithLocale) -> Option<PssEntry> { let mut order = self.order.borrow_mut(); order.retain(|stored_url| stored_url != url); let mut map = self.map.borrow_mut(); map.remove(url) } + /// Evicts the oldest page in the store if we've reached the order limit. + /// This will also traverse the rest of the store to evict any widgets + /// that were only used by that page. + /// + /// This assumes that any references to parts of the store have been + /// dropped, as this will mutably interact with a number of them. + /// + /// Note that this will never affect paths in the keep list, since they + /// don't actually appear in `self.order`. + fn evict_page_if_needed(&self) { + let mut order = self.order.borrow_mut(); + let mut map = self.map.borrow_mut(); + let keep_list = self.keep_list.borrow(); + if order.len() > self.max_size { + // Because this is called on every addition, we can safely assume that it's only + // one over + let old_url = order.remove(0); + // Assuming there's been no tampering, this will be fine + let PssEntry { dependencies, .. } = map.remove(&old_url).unwrap(); + // We want to remove any widgets that this page used that no other page is using + for dep in dependencies.into_iter() { + // First, make sure this isn't in the keep list + if !keep_list.contains(&dep) { + // Invariants say this will be present in both + let entry = map.get_mut(&dep).unwrap(); + // We've evicted this page, so it's no longer a dependent + entry.dependents.retain(|v| v != &old_url); + if entry.dependents.is_empty() { + // Evict this widget + map.remove(&dep).unwrap(); + } + } + } + } + } } impl PageStateStore { /// Freezes the component entries into a new `HashMap` of `String`s to avoid @@ -358,8 +477,11 @@ impl PageStateStore { /// metadata, which will be re-requested from the server. (There is no /// point in freezing that, since it can't be unique for the user's page /// interactions, as it's added directly as the server sends it.) + /// + /// Note that the typed path system uses transparent serialization, and has + /// no additional storage cost. // TODO Avoid literally cloning all the page states here if possible - pub fn freeze_to_hash_map(&self) -> HashMap<String, String> { + pub fn freeze_to_hash_map(&self) -> HashMap<PathMaybeWithLocale, String> { let map = self.map.borrow(); let mut str_map = HashMap::new(); for (k, entry) in map.iter() { @@ -367,7 +489,7 @@ impl PageStateStore { // usage) if let PssState::Some(state) = &entry.state { let v_str = state.freeze(); - str_map.insert(k.to_string(), v_str); + str_map.insert(k.clone(), v_str); } } @@ -385,6 +507,7 @@ impl std::fmt::Debug for PageStateStore { /// /// Note: while it is hypothetically possible for this to hold neither a state /// nor document metadata, that will never happen without user intervention. +#[derive(Debug)] pub struct PssEntry { /// The page state, if any exists. This may come with a guarantee that no /// state will ever exist. @@ -392,6 +515,24 @@ pub struct PssEntry { /// The document metadata of the page, which can be cached to prevent future /// requests to the server. head: Option<String>, + /// A list of widgets this page depends on, by their path. This allows quick + /// indexing of the widgets that should potentially be evicted when the page + /// using them is evicted. (Note that widgets are only evicted when all + /// pages that depend on them have all been evicted.) + /// + /// As there is never a centralized list of the dependencies of any given + /// page, this will be gradually filled out as the page is rendered. + /// (This is why it is critical that pages are pure functions on the + /// state they use with respect to the widgets on which they depend.) + dependencies: Vec<PathMaybeWithLocale>, + /// A list of dependents by path. For pages, this will always be empty. + /// + /// This is used by widgets to declare the pages that depend on them, + /// creating the reverse of the `dependencies` path. This is used so we + /// can quickly iterate through each of the widgets a page uses when + /// we're about to evict it and remove only those that aren't being used + /// by any other pages. + dependents: Vec<PathMaybeWithLocale>, } impl Default for PssEntry { fn default() -> Self { @@ -399,20 +540,18 @@ impl Default for PssEntry { // There could be state later state: PssState::None, head: None, + dependencies: Vec::new(), + dependents: Vec::new(), } } } impl PssEntry { /// Declare that this entry will *never* have state. This should be done by /// macros that definitively know the structure of a page. This action - /// is irrevocable, since a page cannot transition from never taking state - /// to taking some later in Perseus. + /// is revocable under HSR conditions only. /// /// Note that this will not be preserved in freezing (allowing greater /// flexibility of HSR). - /// - /// **Warning:** manually calling in the wrong context this may lead to the - /// complete failure of your application! pub fn set_state_never(&mut self) { self.state = PssState::Never; } @@ -420,21 +559,41 @@ impl PssEntry { pub fn set_head(&mut self, head: String) { self.head = Some(head); } - /// Adds state to this entry. This will return false and do nothing if the - /// entry has been marked as never being able to accept state. - #[must_use] - pub fn set_state(&mut self, state: Box<dyn AnyFreeze>) -> bool { + /// Declares a widget that this page/widget depends on, by its path. + #[cfg(target_arch = "wasm32")] + fn add_dependency(&mut self, path: PathMaybeWithLocale) { + self.dependencies.push(path); + } + /// Declares a page/widget that this widget is used by, by its path. + #[cfg(target_arch = "wasm32")] + fn add_dependent(&mut self, path: PathMaybeWithLocale) { + self.dependents.push(path); + } + /// Adds state to this entry. This will return an error if this entry has + /// previously been marked as having no state. + /// + /// If we're setting state for HSR, this function's should be interpreted + /// with caution: if the user has added state to a template/capsule that + /// previously didn't have state, then nothing in the code will try to + /// set it to never having had state (and there will be nothing in the + /// frozen state for it), which is fine; but, if they *removed* state + /// from an entity that previously had it, this will return an error to the + /// HSR thaw attempt (whcih will try to add the old state back). In that + /// case, the error should be discarded by the caller, who should accept + /// the changed data model. + pub fn set_state(&mut self, state: Box<dyn AnyFreeze>) -> Result<(), ClientError> { if let PssState::Never = self.state { - false + Err(ClientInvariantError::IllegalStateRegistration.into()) } else { self.state = PssState::Some(state); - true + Ok(()) } } } /// The page state of a PSS entry. This is used to determine whether or not we /// need to request data from the server. +#[derive(Debug)] pub enum PssState<T> { /// There is state. Some(T), @@ -447,6 +606,10 @@ pub enum PssState<T> { /// The various things the PSS can contain for a single page. It might have /// state, a head, both, or neither. +/// +/// *Note: the `P` in the `PSS` acronym used to stand for page (pre-widgets), +/// and it now stands for Perseus, as its removal creates a considerably less +/// desirable acronym.* #[derive(Debug)] pub enum PssContains { /// There is no entry for this page. diff --git a/packages/perseus/src/state/suspense.rs b/packages/perseus/src/state/suspense.rs index 0fb7e92f31..91d829ceb0 100644 --- a/packages/perseus/src/state/suspense.rs +++ b/packages/perseus/src/state/suspense.rs @@ -1,4 +1,4 @@ -use super::{rx_result::RxResultIntermediate, Freeze, MakeRx, MakeRxRef, MakeUnrx}; +use super::{rx_result::RxResultRx, Freeze, MakeRx, MakeUnrx}; use futures::Future; use serde::{de::DeserializeOwned, Serialize}; use sycamore::prelude::{RcSignal, Scope}; @@ -21,16 +21,13 @@ use sycamore_futures::spawn_local_scoped; /// The handler this takes is a future, so the asynchronous function handler /// itself should be called without `.await` before being provided to this /// function. -pub fn compute_nested_suspense<'a, T, E, F>( - cx: Scope<'a>, - state: RxResultIntermediate<T, E>, - handler: F, -) where +pub fn compute_nested_suspense<'a, T, E, F>(cx: Scope<'a>, state: RxResultRx<T, E>, handler: F) +where F: Future<Output = Result<(), E>> + 'a, T: MakeRx + Serialize + DeserializeOwned + Clone + 'static, /* Note this `Clone` bound! * (Otherwise cloning goes to * the undelrying `RcSignal`) */ - <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + MakeRxRef + Clone + 'static, + <T as MakeRx>::Rx: MakeUnrx<Unrx = T> + Freeze + Clone + 'static, E: Serialize + DeserializeOwned + Clone + 'static, { spawn_local_scoped(cx, async move { diff --git a/packages/perseus/src/state/template_state.rs b/packages/perseus/src/state/template_state.rs new file mode 100644 index 0000000000..5c96fb722b --- /dev/null +++ b/packages/perseus/src/state/template_state.rs @@ -0,0 +1,86 @@ +use std::{any::TypeId, marker::PhantomData}; + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::Value; + +/// A marker for when the type of template state is unknown. +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub struct UnknownStateType; + +/// A wrapper for template state stored as a [`Value`]. This loses the +/// underlying type information, but allows for serialization. This is a +/// necessary compromise, since, without types being first-class citizens in +/// Rust, full template type management appears presently impossible. +#[derive(Clone, Debug)] +pub struct TemplateStateWithType<T: Serialize + DeserializeOwned> { + /// The underlying state, stored as a [`Value`]. + pub(crate) state: Value, + /// The user's actual type. + ty: PhantomData<T>, +} +impl<T: Serialize + DeserializeOwned + 'static> TemplateStateWithType<T> { + /// Convert the template state into its underlying concrete type, when that + /// type is known. + pub(crate) fn into_concrete(self) -> Result<T, serde_json::Error> { + serde_json::from_value(self.state) + } + /// Creates a new empty template state. + pub fn empty() -> Self { + Self { + state: Value::Null, + ty: PhantomData, + } + } + /// Checks if this state is empty. This not only checks for states created + /// as `Value::Null`, but also those created with `()` explicitly set as + /// their underlying type. + pub fn is_empty(&self) -> bool { + self.state.is_null() || TypeId::of::<T>() == TypeId::of::<()>() + } + /// Creates a new template state by deserializing from a string. + pub(crate) fn from_str(s: &str) -> Result<Self, serde_json::Error> { + let state = Self { + state: serde_json::from_str(s)?, + ty: PhantomData, + }; + Ok(state) + } + /// Creates a new template state from a pre-deserialized [`Value`]. + /// + /// Note that end users should almost always prefer `::from_str()`, and this + /// is intended primarily for server integrations. + pub fn from_value(v: Value) -> Self { + Self { + state: v, + ty: PhantomData, + } + } + /// Transform this into a [`TemplateStateWithType`] with a different type. + /// Once this is done, `.to_concrete()` can be used to get this type out + /// of the container. + pub(crate) fn change_type<U: Serialize + DeserializeOwned>(self) -> TemplateStateWithType<U> { + TemplateStateWithType { + state: self.state, + ty: PhantomData, + } + } +} + +// Any user state should be able to be made into this with a simple `.into()` +// for ergonomics +impl<T: Serialize + DeserializeOwned> From<T> for TemplateState { + fn from(state: T) -> Self { + Self { + // This will happen at Perseus build-time (and should never happen unless the user uses non-string map types) + state: serde_json::to_value(state).expect("serializing template state failed (this is almost certainly due to non-string map keys in your types, which can't be serialized by serde)"), + ty: PhantomData, + } + } +} + +/// A type alias for template state that has been converted into a [`Value`] +/// without retaining the information of the original type, which is done +/// internally to eliminate the need for generics, which cannot be used +/// internally in Perseus for user state. The actual type is restored at the +/// last minute when it's needed. +pub type TemplateState = TemplateStateWithType<UnknownStateType>; diff --git a/packages/perseus/src/stores/mutable.rs b/packages/perseus/src/stores/mutable.rs index bdebf1b33b..320fba4621 100644 --- a/packages/perseus/src/stores/mutable.rs +++ b/packages/perseus/src/stores/mutable.rs @@ -20,6 +20,11 @@ use tokio::{ /// a database, though this should be as low-latency as possible, since reads /// and writes are required at extremely short-notice as new user requests /// arrive. +/// +/// **Warning:** the `NotFound` error is integral to Perseus' internal +/// operation, and must be returned if an asset does not exist. Do NOT return +/// any other error if everything else worked, but an asset simply did not +/// exist, or the entire render system will come crashing down around you! #[async_trait::async_trait] pub trait MutableStore: std::fmt::Debug + Clone + Send + Sync { /// Reads data from the named asset. diff --git a/packages/perseus/src/template/capsule.rs b/packages/perseus/src/template/capsule.rs new file mode 100644 index 0000000000..ae817d2390 --- /dev/null +++ b/packages/perseus/src/template/capsule.rs @@ -0,0 +1,361 @@ +use crate::{ + errors::ClientError, + path::PathMaybeWithLocale, + reactor::Reactor, + state::{AnyFreeze, MakeRx, MakeUnrx, TemplateState, UnreactiveState}, +}; + +use super::{Entity, PreloadInfo, TemplateInner}; +use serde::{de::DeserializeOwned, Serialize}; +use std::sync::Arc; +use sycamore::{ + prelude::{create_child_scope, create_scope, BoundedScope, Scope, ScopeDisposer}, + view::View, + web::Html, +}; + +/// The type of functions that are given a state and properties to render a +/// widget. +pub(crate) type CapsuleFn<G, P> = Box< + dyn for<'a> Fn( + Scope<'a>, + PreloadInfo, + TemplateState, + P, + PathMaybeWithLocale, // Widget path + PathMaybeWithLocale, // Caller path + ) -> Result<(View<G>, ScopeDisposer<'a>), ClientError> + + Send + + Sync, +>; + +/// A *capsule*, a special type of template in Perseus that can also accept +/// *properties*. Capsules are basically a very special type of Sycamore +/// component that can integrate fully with Perseus' state platform, generating +/// their own states at build-time, request-time, etc. They're then used in one +/// or more pages, and provided extra properties. +/// +/// Note that capsules store their view functions and fallbacks independently of +/// their underlying templates, for properties support. +pub struct Capsule<G: Html, P: Clone + 'static> { + /// The underlying entity (in this case, a capsule). + pub(crate) inner: Entity<G>, + /// The capsule rendering function, which is a template function that also + /// takes properties. + capsule_view: CapsuleFn<G, P>, + /// A function that returns the fallback view to be rendered between when + /// the page is ready and when the capsule's state has been fetched. + /// + /// Note that this starts as `None`, but, if it's not set, `PerseusApp` will + /// panic. So, for later code, this can be assumed to be always `Some`. + /// + /// This will not be defined for templates, only for capsules. + #[allow(clippy::type_complexity)] + pub(crate) fallback: Option<Arc<dyn Fn(Scope, P) -> View<G> + Send + Sync>>, +} +impl<G: Html, P: Clone + 'static> std::fmt::Debug for Capsule<G, P> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Capsule").finish() + } +} + +/// The equivalent of [`TemplateInner`] for capsules. +/// +/// # Implementation +/// +/// Really, this is just a wrapper over [`TemplateInner`] with the additional +/// methods capsules need. For example, templates have fallback views on their +/// own, they just don't use them, and there's no way to set them as an end +/// user. This means Perseus can treat templates and capsules in the same way +/// internally, since they both have the same representation. Types like this +/// are mere convenience wrappers. +pub struct CapsuleInner<G: Html, P: Clone + 'static> { + template_inner: TemplateInner<G>, + capsule_view: CapsuleFn<G, P>, + /// A function that returns the fallback view to be rendered between when + /// the page is ready and when the capsule's state has been fetched. + /// + /// Note that this starts as `None`, but, if it's not set, `PerseusApp` will + /// panic. So, for later code, this can be assumed to be always `Some`. + /// + /// This will not be defined for templates, only for capsules. + #[allow(clippy::type_complexity)] + pub(crate) fallback: Option<Arc<dyn Fn(Scope, P) -> View<G> + Send + Sync>>, +} +impl<G: Html, P: Clone + 'static> std::fmt::Debug for CapsuleInner<G, P> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CapsuleInner") + .field("template_inner", &self.template_inner) + .finish_non_exhaustive() + } +} + +impl<G: Html, P: Clone + 'static> Capsule<G, P> { + /// Creates a new [`CapsuleInner`] from the given [`TemplateInner`]. In + /// Perseus, capsules are really just special kinds of pages, so you + /// create them by first creating the underlying template. To make sure + /// you get a capsule instead of a template, you just don't call + /// `.build()` on the template, instead passing the [`TemplateInner`] to + /// this function. + /// + /// **Warning:** [`TemplateInner`] has methods like `.view()` and + /// `.view_with_state()` for setting the views of your templates, but you + /// shouldn't use those when you're building a capsule, because those + /// functions won't let you use *properties* that can be passed from + /// pages that use your capsule. Instead, construct a [`TemplateInner`] + /// that has no views, and then use the `.view()` etc. functions on + /// [`CapsuleInner`] instead. (Unfortunately, dereferncing doesn't work + /// with the builder pattern, so this is the best we can do in Rust + /// right now.) + /// + /// You will need to call `.build()` when you're done with this to get a + /// full [`Capsule`]. + pub fn build(mut template_inner: TemplateInner<G>) -> CapsuleInner<G, P> { + template_inner.is_capsule = true; + // Produce nice errors to make it clear that heads and headers don't work with + // capsules + #[cfg(not(target_arch = "wasm32"))] + { + assert!( + template_inner.head.is_none(), + "capsules cannot set document metadata" + ); + assert!( + template_inner.set_headers.is_none(), + "capsules cannot set headers" + ); + } + // Wipe the template's view function to make sure the errors aren't obscenely + // weird + template_inner.view = Box::new(|_, _, _, _| Ok((View::empty(), create_scope(|_| {})))); + CapsuleInner { + template_inner, + capsule_view: Box::new(|_, _, _, _, _, _| Ok((View::empty(), create_scope(|_| {})))), + // This must be manually specified + fallback: None, + } + } + + /// Executes the user-given function that renders the *widget* on the + /// client-side ONLY. This takes in an existing global state. This will + /// ignore its internal scope disposer, since the given scope **must** + /// be a page-level scope, which will be disposed from the root when the + /// page changes, thereby disposing of all the child scopes, like those + /// used for widgets. + /// + /// This should NOT be used to render pages! + #[cfg(target_arch = "wasm32")] + #[allow(clippy::too_many_arguments)] + pub(crate) fn render_widget_for_template_client( + &self, + path: PathMaybeWithLocale, + caller_path: PathMaybeWithLocale, + props: P, + cx: Scope, + preload_info: PreloadInfo, + ) -> Result<View<G>, ClientError> { + // The template state is ignored by widgets, they fetch it themselves + // asynchronously + let (view, _disposer) = (self.capsule_view)( + cx, + preload_info, + TemplateState::empty(), + props, + path, + caller_path, + )?; + Ok(view) + } + /// Executes the user-given function that renders the capsule on the + /// server-side ONLY. This takes the scope from a previous call of + /// `.render_for_template_server()`, assuming the reactor has already + /// been fully instantiated. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn render_widget_for_template_server( + &self, + path: PathMaybeWithLocale, + state: TemplateState, + props: P, + cx: Scope, + ) -> Result<View<G>, ClientError> { + // This is used for widget preloading, which doesn't occur on the engine-side + let preload_info = PreloadInfo {}; + // We don't care about the scope disposer, since this scope is unique anyway; + // the caller path is also irrelevant except on the browser + let (view, _) = (self.capsule_view)( + cx, + preload_info, + state, + props, + path, + PathMaybeWithLocale(String::new()), + )?; + Ok(view) + } +} +impl<G: Html, P: Clone + 'static> CapsuleInner<G, P> { + /// Declares the fallback view to render for this capsule. When Perseus + /// renders a page of your app, it fetches the page itself, along with + /// all the capsules it needs. If the page is ready before all the + /// capsules, then it will be displayed immediately, with fallback views + /// for the capsules that aren't ready yet. Once they are ready, they + /// will be updated. + /// + /// This fallback view cannot access any of the state that the capsule + /// generated, but it can access any properties provided to it by the + /// page, along with a translator and the like. This view is fully + /// reactive, it just doesn't have the state yet. + /// + /// **Warning:** if you do not set a fallback view for a capsule, your app + /// will not compile! + pub fn fallback(mut self, view: impl Fn(Scope, P) -> View<G> + Send + Sync + 'static) -> Self { + { + self.fallback = Some(Arc::new(view)); + } + self + } + /// Sets the fallback for this capsule to be an empty view. + /// + /// You should be careful using this function in production, since it is + /// very often not what you actually want (especially since empty views + /// have no size, which may compromise your layouts: be sure to test + /// this). + pub fn empty_fallback(mut self) -> Self { + { + self.fallback = Some(Arc::new(|cx, _| sycamore::view! { cx, })); + } + self + } + /// Builds a full [`Capsule`] from this [`CapsuleInner`], consuming it in + /// the process. Once called, the capsule cannot be modified anymore, + /// and it will be placed into a smart pointer, allowing it to be cloned + /// freely with minimal costs. + /// + /// You should call this just before you return your capsule. + pub fn build(self) -> Capsule<G, P> { + Capsule { + inner: Entity::from(self.template_inner), + capsule_view: self.capsule_view, + fallback: self.fallback, + } + } + + // --- Shadow `.view()` functions for properties --- + // These will set dummy closures for the underlying templates, as capsules + // maintain their own separate functions, which can use properties in line + // with the known generics. As capsules are themselves used as their own + // components, these functions can therefore be accessed. + + /// Sets the rendering function to use for capsules that take reactive + /// state. Capsules that do not take state should use `.view()` instead. + /// + /// The closure wrapping this performs will automatically handle suspense + /// state. + // Generics are swapped here for nicer manual specification + pub fn view_with_state<I, F>(mut self, val: F) -> Self + where + // The state is made reactive on the child + F: for<'app, 'child> Fn(BoundedScope<'app, 'child>, &'child I, P) -> View<G> + + Clone + + Send + + Sync + + 'static, + I: MakeUnrx + AnyFreeze + Clone, + I::Unrx: MakeRx<Rx = I> + Serialize + DeserializeOwned + Send + Sync + Clone + 'static, + { + self.template_inner.view = + Box::new(|_, _, _, _| panic!("attempted to call template rendering logic for widget")); + #[cfg(target_arch = "wasm32")] + let entity_name = self.template_inner.get_path(); + #[cfg(target_arch = "wasm32")] + let fallback_fn = self.fallback.clone(); // `Arc`ed, heaven help us + self.capsule_view = Box::new( + #[allow(unused_variables)] + move |app_cx, preload_info, template_state, props, path, caller_path| { + let reactor = Reactor::<G>::from_cx(app_cx); + reactor.get_widget_view::<I::Unrx, _, P>( + app_cx, + path, + caller_path, + #[cfg(target_arch = "wasm32")] + entity_name.clone(), + template_state, + props, + #[cfg(target_arch = "wasm32")] + preload_info, + val.clone(), + #[cfg(target_arch = "wasm32")] + fallback_fn.as_ref().unwrap(), + ) + }, + ); + self + } + /// Sets the rendering function to use for capsules that take unreactive + /// state. + pub fn view_with_unreactive_state<F, S>(mut self, val: F) -> Self + where + F: Fn(Scope, S, P) -> View<G> + Clone + Send + Sync + 'static, + S: MakeRx + Serialize + DeserializeOwned + UnreactiveState + 'static, + <S as MakeRx>::Rx: AnyFreeze + Clone + MakeUnrx<Unrx = S>, + { + self.template_inner.view = + Box::new(|_, _, _, _| panic!("attempted to call template rendering logic for widget")); + #[cfg(target_arch = "wasm32")] + let entity_name = self.template_inner.get_path(); + #[cfg(target_arch = "wasm32")] + let fallback_fn = self.fallback.clone(); // `Arc`ed, heaven help us + self.capsule_view = Box::new( + #[allow(unused_variables)] + move |app_cx, preload_info, template_state, props, path, caller_path| { + let reactor = Reactor::<G>::from_cx(app_cx); + reactor.get_unreactive_widget_view( + app_cx, + path, + caller_path, + #[cfg(target_arch = "wasm32")] + entity_name.clone(), + template_state, + props, + #[cfg(target_arch = "wasm32")] + preload_info, + val.clone(), + #[cfg(target_arch = "wasm32")] + fallback_fn.as_ref().unwrap(), + ) + }, + ); + self + } + + /// Sets the rendering function for capsules that take no state. Capsules + /// that do take state should use `.view_with_state()` instead. + pub fn view<F>(mut self, val: F) -> Self + where + F: Fn(Scope, P) -> View<G> + Send + Sync + 'static, + { + self.template_inner.view = + Box::new(|_, _, _, _| panic!("attempted to call template rendering logic for widget")); + self.capsule_view = Box::new( + #[allow(unused_variables)] + move |app_cx, _preload_info, _template_state, props, path, caller_path| { + let reactor = Reactor::<G>::from_cx(app_cx); + // Declare that this page/widget will never take any state to enable full + // caching + reactor.register_no_state(&path, true); + // And declare the relationship between the widget and its caller + #[cfg(target_arch = "wasm32")] + reactor.state_store.declare_dependency(&path, &caller_path); + + // Nicely, if this is a widget, this means there need be no network requests + // at all! + let mut view = View::empty(); + let disposer = create_child_scope(app_cx, |child_cx| { + view = val(child_cx, props); + }); + Ok((view, disposer)) + }, + ); + self + } +} diff --git a/packages/perseus/src/template/core.rs b/packages/perseus/src/template/core.rs deleted file mode 100644 index 98588669fd..0000000000 --- a/packages/perseus/src/template/core.rs +++ /dev/null @@ -1,1275 +0,0 @@ -// This file contains logic to define how templates are rendered - -use std::any::TypeId; -use std::marker::PhantomData; - -#[cfg(not(target_arch = "wasm32"))] -use super::default_headers; -use super::RenderCtx; -use crate::errors::*; -#[cfg(not(target_arch = "wasm32"))] -use crate::make_async_trait; -use crate::state::AnyFreeze; -use crate::state::MakeRx; -use crate::state::MakeRxRef; -use crate::state::MakeUnrx; -use crate::state::UnreactiveState; -use crate::translator::Translator; -use crate::utils::provide_context_signal_replace; -#[cfg(not(target_arch = "wasm32"))] -use crate::utils::AsyncFnReturn; -#[cfg(not(target_arch = "wasm32"))] -use crate::utils::ComputedDuration; -use crate::utils::PerseusDuration; /* We do actually want this in both the engine and the - * browser */ -use crate::router::RouteManager; -use crate::Html; -#[cfg(not(target_arch = "wasm32"))] -use crate::Request; -#[cfg(not(target_arch = "wasm32"))] -use crate::SsrNode; -#[cfg(not(target_arch = "wasm32"))] -use futures::Future; -#[cfg(not(target_arch = "wasm32"))] -use http::header::HeaderMap; -use serde::de::DeserializeOwned; -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value; -use sycamore::prelude::Scope; -use sycamore::prelude::View; -#[cfg(not(target_arch = "wasm32"))] -use sycamore::utils::hydrate::with_no_hydration_context; - -/// A marker for when the type of template state is unknown. -#[derive(Serialize, Deserialize, Clone, Copy, Debug)] -pub struct UnknownStateType; - -/// A wrapper for template state stored as a [`Value`]. This loses the -/// underlying type information, but allows for serialization. This is a -/// necessary compromise, since, without types being first-class citizens in -/// Rust, full template type management appears presently impossible. -#[derive(Clone, Debug)] -pub struct TemplateStateWithType<T: Serialize + DeserializeOwned> { - /// The underlying state, stored as a [`Value`]. - pub(crate) state: Value, - /// The user's actual type. - ty: PhantomData<T>, -} -impl<T: Serialize + DeserializeOwned + 'static> TemplateStateWithType<T> { - /// Convert the template state into its underlying concrete type, when that - /// type is known. - pub(crate) fn to_concrete(self) -> Result<T, serde_json::Error> { - serde_json::from_value(self.state) - } - /// Creates a new empty template state. - pub fn empty() -> Self { - Self { - state: Value::Null, - ty: PhantomData, - } - } - /// Checks if this state is empty. This not only checks for states created - /// as `Value::Null`, but also those created with `()` explicitly set as - /// their underlying type. - pub fn is_empty(&self) -> bool { - self.state.is_null() || TypeId::of::<T>() == TypeId::of::<()>() - } - /// Creates a new template state by deserializing from a string. - pub(crate) fn from_str(s: &str) -> Result<Self, serde_json::Error> { - let state = Self { - state: serde_json::from_str(s)?, - ty: PhantomData, - }; - Ok(state) - } - /// Creates a new template state from a pre-deserialized [`Value`]. - /// - /// Note that end users should almost always prefer `::from_str()`, and this - /// is intended primarily for server integrations. - pub fn from_value(v: Value) -> Self { - Self { - state: v, - ty: PhantomData, - } - } - /// Transform this into a [`TemplateStateWithType`] with a different type. - /// Once this is done, `.to_concrete()` can be used to get this type out - /// of the container. - pub(crate) fn change_type<U: Serialize + DeserializeOwned>(self) -> TemplateStateWithType<U> { - TemplateStateWithType { - state: self.state, - ty: PhantomData, - } - } -} - -// Any user state should be able to be made into this with a simple `.into()` -// for ergonomics -impl<T: Serialize + DeserializeOwned> From<T> for TemplateState { - fn from(state: T) -> Self { - Self { - // This will happen at Perseus build-time (and should never happen unless the user uses non-string map types) - state: serde_json::to_value(state).expect("serializing template state failed (this is almost certainly due to non-string map keys in your types, which can't be serialized by serde)"), - ty: PhantomData, - } - } -} - -/// A type alias for template state that has been converted into a [`Value`] -/// without retaining the information of the original type, which is done -/// internally to eliminate the need for generics, which cannot be used -/// internally in Perseus for user state. The actual type is restored at the -/// last minute when it's needed. -pub type TemplateState = TemplateStateWithType<UnknownStateType>; - -/// 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<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>; -/// A generic error type that can be adapted for any errors the user may want to -/// return from a render function, as with [`RenderFnResult<T>`]. 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. -/// -/// Note that you can automatically convert from your error type into this with -/// `.into()` or `?`, which will blame the server for the error by default and -/// return a *500 Internal Server Error* HTTP status code. Otherwise, you'll -/// need to manually instantiate [`GenericErrorWithCause`] and return that as -/// the error type. Alternatively, you could use -/// [`blame_err!`](crate::blame_err). -pub type RenderFnResultWithCause<T> = std::result::Result<T, GenericErrorWithCause>; - -/// The output of the build seed system, which should be generated by a user -/// function for each template. -pub struct BuildPaths { - /// The paths to render underneath this template, without the template name - /// or leading forward slashes. - pub paths: Vec<String>, - /// Any additional state, of an arbitrary type, to be passed to all future - /// state generation. This can be used to avoid unnecessary duplicate - /// filesystem reads, or the like. - /// - /// The exact type information from this is deliberately discarded. - pub extra: TemplateState, -} - -/// The information any function that generates state will be provided. -/// -/// This must be able to be shared safely between threads. -#[derive(Clone)] -pub struct StateGeneratorInfo<B: Serialize + DeserializeOwned + Send + Sync> { - /// The path it is generating for, not including the template name or - /// locale. - /// - /// **Warning:** previous versions of Perseus used to prefix this with the - /// template name, and this is no longer done, for convenience of - /// handling. - pub path: String, - /// The locale it is generating for. - pub locale: String, - /// Any extra data from the template's build seed. - pub(crate) extra: TemplateStateWithType<B>, -} -impl<B: Serialize + DeserializeOwned + Send + Sync + 'static> StateGeneratorInfo<B> { - /// Transform the underlying [`TemplateStateWithType`] into one with a - /// different type. Once this is done, `.to_concrete()` can be used to - /// get this type out of the container. - #[cfg(not(target_arch = "wasm32"))] // Just to silence clippy (if you need to remove this, do) - fn change_type<U: Serialize + DeserializeOwned + Send + Sync>(self) -> StateGeneratorInfo<U> { - StateGeneratorInfo { - path: self.path, - locale: self.locale, - extra: self.extra.change_type(), - } - } - /// Get the extra build state as an owned type. - /// - /// # Panics - /// Hypothetically, if there were a failure in the Perseus core such that - /// your extra build state ended up being malformed, this would panic. - /// However, this should never happen, as there are multiplr layers of - /// checks before this that should catch such an event. If this panics, - /// and if keeps panicking after `perseus clean`, please report it as a - /// bug (assuming all your types are correct). - pub fn get_extra(&self) -> B { - match B::deserialize(&self.extra.state) { - Ok(extra) => extra, - // This should never happen... - Err(err) => panic!( - "unrecoverable extra build state extraction failure: {:#?}", - err - ), - } - } -} - -// A series of asynchronous closure traits that prevent the user from having to -// pin their functions -#[cfg(not(target_arch = "wasm32"))] -make_async_trait!(GetBuildPathsFnType, RenderFnResult<BuildPaths>); -// The build state strategy needs an error cause if it's invoked from -// incremental -#[cfg(not(target_arch = "wasm32"))] -make_async_trait!( - GetBuildStateFnType, - RenderFnResultWithCause<TemplateState>, - info: StateGeneratorInfo<UnknownStateType> -); -#[cfg(not(target_arch = "wasm32"))] -make_async_trait!( - GetRequestStateFnType, - RenderFnResultWithCause<TemplateState>, - info: StateGeneratorInfo<UnknownStateType>, - req: Request -); -#[cfg(not(target_arch = "wasm32"))] -make_async_trait!( - ShouldRevalidateFnType, - RenderFnResultWithCause<bool>, - info: StateGeneratorInfo<UnknownStateType>, - req: Request -); -#[cfg(not(target_arch = "wasm32"))] -make_async_trait!( - AmalgamateStatesFnType, - RenderFnResultWithCause<TemplateState>, - info: StateGeneratorInfo<UnknownStateType>, - build_state: TemplateState, - request_state: TemplateState -); - -// These traits are for the functions users provide to us! They are NOT stored -// internally! -#[cfg(not(target_arch = "wasm32"))] -make_async_trait!( - GetBuildStateUserFnType<S: Serialize + DeserializeOwned + MakeRx, B: Serialize + DeserializeOwned + Send + Sync>, - RenderFnResultWithCause<S>, - info: StateGeneratorInfo<B> -); -#[cfg(not(target_arch = "wasm32"))] -make_async_trait!( - GetRequestStateUserFnType<S: Serialize + DeserializeOwned + MakeRx, B: Serialize + DeserializeOwned + Send + Sync>, - RenderFnResultWithCause<S>, - info: StateGeneratorInfo<B>, - req: Request -); -#[cfg(not(target_arch = "wasm32"))] -make_async_trait!( - ShouldRevalidateUserFnType<B: Serialize + DeserializeOwned + Send + Sync>, - RenderFnResultWithCause<bool>, - info: StateGeneratorInfo<B>, - req: Request -); -#[cfg(not(target_arch = "wasm32"))] -make_async_trait!( - AmalgamateStatesUserFnType<S: Serialize + DeserializeOwned + MakeRx, B: Serialize + DeserializeOwned + Send + Sync>, - RenderFnResultWithCause<S>, - info: StateGeneratorInfo<B>, - build_state: S, - request_state: S -); - -// 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` -/// inside `PageProps`. If you're using i18n, an `Rc<Translator>` will also be -/// made available through Sycamore's [context system](https://sycamore-rs.netlify.app/docs/advanced/advanced_reactivity). -pub type TemplateFn<G> = - Box<dyn for<'a> Fn(Scope<'a>, RouteManager<'a, G>, TemplateState, String) + 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). -#[cfg(not(target_arch = "wasm32"))] -pub type HeadFn = Box<dyn Fn(Scope, TemplateState) -> View<SsrNode> + Send + Sync>; -#[cfg(not(target_arch = "wasm32"))] -/// The type of functions that modify HTTP response headers. -pub type SetHeadersFn = Box<dyn Fn(TemplateState) -> HeaderMap + Send + Sync>; -/// The type of functions that get build paths. -#[cfg(not(target_arch = "wasm32"))] -pub type GetBuildPathsFn = Box<dyn GetBuildPathsFnType + Send + Sync>; -/// The type of functions that get build state. -#[cfg(not(target_arch = "wasm32"))] -pub type GetBuildStateFn = Box<dyn GetBuildStateFnType + Send + Sync>; -/// The type of functions that get request state. -#[cfg(not(target_arch = "wasm32"))] -pub type GetRequestStateFn = Box<dyn GetRequestStateFnType + Send + Sync>; -/// The type of functions that check if a template should revalidate. -#[cfg(not(target_arch = "wasm32"))] -pub type ShouldRevalidateFn = Box<dyn ShouldRevalidateFnType + Send + Sync>; -/// The type of functions that amalgamate build and request states. -#[cfg(not(target_arch = "wasm32"))] -pub type AmalgamateStatesFn = Box<dyn AmalgamateStatesFnType + Send + Sync>; - -/// A single template in an app. Each template is comprised of a Sycamore view, -/// a state type, and some functions involved with generating that state. Pages -/// can then be generated from particular states. For instance, a single `docs` -/// template could have a state `struct` that stores a title and some content, -/// which could then render as many pages as desired. -/// -/// You can read more about the templates system [here](https://arctic-hen7.github.io/perseus/en-US/docs/next/core-principles). -/// -/// Note that all template states are passed around as `String`s to avoid -/// type maps and other inefficiencies, since they need to be transmitted over -/// the network anyway. As such, this `struct` is entirely state-agnostic, -/// since all the state-relevant functions merely return `String`s. The -/// various proc macros used to annotate such functions (e.g. -/// `#[perseus::build_state]`) perform serialization/deserialization -/// automatically for convenience. -pub struct Template<G: Html> { - /// The path to the root of the template. Any build paths will be inserted - /// under this. - path: String, - /// A function that will render your template. This will be provided the - /// rendered properties, and will be used whenever your template needs - /// to be prerendered in some way. This should be very similar to the - /// function that hydrates your template on the client side. - /// This will be executed inside `sycamore::render_to_string`, and should - /// return a `Template<SsrNode>`. This takes an `Option<Props>` - /// because otherwise efficient typing is almost impossible for templates - /// without any properties (solutions welcome in PRs!). - template: TemplateFn<G>, - /// A function that will be used to populate the document's `<head>` with - /// metadata such as the title. This will be passed state in - /// the same way as `template`, but will always be rendered to a string, - /// which will then be interpolated directly into the `<head>`, - /// so reactivity here will not work! - #[cfg(not(target_arch = "wasm32"))] - head: HeadFn, - /// A function to be run when the server returns an HTTP response. This - /// should return headers for said response, given the template's state. - /// The most common use-case of this is to add cache control that respects - /// revalidation. This will only be run on successful responses, and - /// does have the power to override existing headers. By default, this will - /// create sensible cache control headers. - #[cfg(not(target_arch = "wasm32"))] - set_headers: SetHeadersFn, - /// A function that generates the information to begin building a template. - /// This is responsible for generating all the paths that will built for - /// that template at build-time (which may later be extended with - /// incremental generation), along with the generation of any extra - /// state that may be collectively shared by other state generating - /// functions. - #[cfg(not(target_arch = "wasm32"))] - get_build_paths: Option<GetBuildPathsFn>, - /// Defines whether or not any new paths that match this template will be - /// prerendered and cached in production. This allows you to - /// have potentially billions of templates and retain a super-fast build - /// process. The first user will have an ever-so-slightly slower - /// experience, and everyone else gets the benefits afterwards. This - /// requires `get_build_paths`. Note that the template root will NOT - /// be rendered on demand, and must be explicitly defined if it's wanted. It - /// can use a different template. - #[cfg(not(target_arch = "wasm32"))] - incremental_generation: bool, - /// A function that gets the initial state to use to prerender the template - /// at build time. This will be passed the path of the template, and - /// will be run for any sub-paths. - #[cfg(not(target_arch = "wasm32"))] - get_build_state: Option<GetBuildStateFn>, - /// A function that will run on every request to generate a state for that - /// request. This allows server-side-rendering. This can be used with - /// `get_build_state`, though custom amalgamation logic must be provided. - #[cfg(not(target_arch = "wasm32"))] - get_request_state: Option<GetRequestStateFn>, - /// A function to be run on every request to check if a template prerendered - /// at build-time should be prerendered again. If used with - /// `revalidate_after`, this function will only be run after that time - /// period. This function will not be parsed anything specific to the - /// request that invoked it. - #[cfg(not(target_arch = "wasm32"))] - should_revalidate: Option<ShouldRevalidateFn>, - /// A length of time after which to prerender the template again. The given - /// duration will be waited for, and the next request after it will lead - /// to a revalidation. Note that, if this is used with incremental - /// generation, the counter will only start after the first render - /// (meaning if you expect a weekly re-rendering cycle for all pages, - /// they'd likely all be out of sync, you'd need to manually implement - /// that with `should_revalidate`). - #[cfg(not(target_arch = "wasm32"))] - revalidate_after: Option<ComputedDuration>, - /// Custom logic to amalgamate potentially different states generated at - /// build and request time. This is only necessary if your template uses - /// both `build_state` and `request_state`. If not specified and both are - /// generated, request state will be prioritized. - #[cfg(not(target_arch = "wasm32"))] - amalgamate_states: Option<AmalgamateStatesFn>, -} -impl<G: Html> std::fmt::Debug for Template<G> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Template") - .field("path", &self.path) - .field("template", &"TemplateFn") - .field("head", &"HeadFn") - .field("set_headers", &"SetHeadersFn") - // TODO Server-specific stuff - .finish() - } -} -impl<G: Html> Template<G> { - /// Creates a new [`Template`]. By default, this has absolutely no - /// associated data. If rendered, it would result in a blank screen. - pub fn new(path: impl Into<String> + std::fmt::Display) -> Self { - Self { - path: path.to_string(), - template: Box::new(|_, _, _, _| {}), - // Unlike `template`, this may not be set at all (especially in very simple apps) - #[cfg(not(target_arch = "wasm32"))] - head: Box::new(|cx, _| sycamore::view! { cx, }), - // We create sensible header defaults here - #[cfg(not(target_arch = "wasm32"))] - set_headers: Box::new(|_| default_headers()), - #[cfg(not(target_arch = "wasm32"))] - get_build_paths: None, - #[cfg(not(target_arch = "wasm32"))] - incremental_generation: false, - #[cfg(not(target_arch = "wasm32"))] - get_build_state: None, - #[cfg(not(target_arch = "wasm32"))] - get_request_state: None, - #[cfg(not(target_arch = "wasm32"))] - should_revalidate: None, - #[cfg(not(target_arch = "wasm32"))] - revalidate_after: None, - #[cfg(not(target_arch = "wasm32"))] - amalgamate_states: None, - } - } - - // Render executors - /// Executes the user-given function that renders the template on the - /// client-side ONLY. This takes in an existing global state. - #[cfg(target_arch = "wasm32")] - #[allow(clippy::too_many_arguments)] - pub fn render_for_template_client<'a>( - &self, - path: String, - state: TemplateState, - cx: Scope<'a>, - route_manager: &'a RouteManager<'a, G>, - // Taking a reference here involves a serious risk of runtime panics, unfortunately (it's - // simpler to own it at this point, and we clone it anyway internally) - translator: Translator, - ) { - // The router component has already set up all the elements of context needed by - // the rest of the system, we can get on with rendering the template All - // we have to do is provide the translator, replacing whatever is present - provide_context_signal_replace(cx, translator); - - (self.template)(cx, route_manager.clone(), state, path); - } - /// Executes the user-given function that renders the template on the - /// server-side ONLY. This automatically initializes an isolated global - /// state. - #[cfg(not(target_arch = "wasm32"))] - pub fn render_for_template_server<'a>( - &self, - path: String, - state: TemplateState, - global_state: TemplateState, - cx: Scope<'a>, - translator: &Translator, - ) -> View<G> { - use std::rc::Rc; - - // The context we have here has no context elements set on it, so we set all the - // defaults (job of the router component on the client-side) - // We don't need the value, we just want the context instantiations - let _ = RenderCtx::server(global_state).set_ctx(cx); - // And now provide a translator separately - provide_context_signal_replace(cx, translator.clone()); - // Similarly, we can invent a route manager on the spot - let route_manager = RouteManager::new(cx); - // We don't need to clean up the page disposer, because the child scope will be - // removed properly when the `cx` this function was given is terminated - (self.template)(cx, route_manager.clone(), state, path); - - let view_rc = route_manager.view.take(); - // TODO Valid to unwrap here? (We should be the only reference holder, since we - // created it...) - Rc::try_unwrap(view_rc).unwrap() - } - /// Executes the user-given function that renders the document `<head>`, - /// 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. - #[cfg(not(target_arch = "wasm32"))] - pub fn render_head_str( - &self, - state: TemplateState, - global_state: TemplateState, - translator: &Translator, - ) -> String { - sycamore::render_to_string(|cx| { - // The context we have here has no context elements set on it, so we set all the - // defaults (job of the router component on the client-side) - // We don't need the value, we just want the context instantiations - // We don't need any page state store here - let _ = RenderCtx::server(global_state).set_ctx(cx); - // And now provide a translator separately - provide_context_signal_replace(cx, translator.clone()); - // We don't want to generate hydration keys for the head because it is static. - with_no_hydration_context(|| (self.head)(cx, state)) - }) - } - /// Gets the list of templates that should be prerendered for at build-time. - #[cfg(not(target_arch = "wasm32"))] - pub async fn get_build_paths(&self) -> Result<BuildPaths, ServerError> { - if let Some(get_build_paths) = &self.get_build_paths { - let res = get_build_paths.call().await; - match res { - Ok(res) => Ok(res), - Err(err) => Err(ServerError::RenderFnFailed { - fn_name: "get_build_paths".to_string(), - template_name: self.get_path(), - cause: ErrorCause::Server(None), - source: err, - }), - } - } else { - Err(BuildError::TemplateFeatureNotEnabled { - template_name: self.path.clone(), - feature_name: "build_paths".to_string(), - } - .into()) - } - } - /// Gets the initial state for a template. This needs to be passed the full - /// path of the template, which may be one of those generated by - /// `.get_build_paths()`. This also needs the locale being rendered to so - /// that more complex applications like custom documentation systems can - /// be enabled. - #[cfg(not(target_arch = "wasm32"))] - pub async fn get_build_state( - &self, - info: StateGeneratorInfo<UnknownStateType>, - ) -> Result<TemplateState, ServerError> { - if let Some(get_build_state) = &self.get_build_state { - let res = get_build_state.call(info).await; - match res { - Ok(res) => Ok(res), - Err(GenericErrorWithCause { error, cause }) => Err(ServerError::RenderFnFailed { - fn_name: "get_build_state".to_string(), - template_name: self.get_path(), - cause, - source: error, - }), - } - } else { - Err(BuildError::TemplateFeatureNotEnabled { - template_name: self.path.clone(), - feature_name: "build_state".to_string(), - } - .into()) - } - } - /// Gets the request-time state for a template. This is equivalent to SSR, - /// and will not be performed at build-time. Unlike `.get_build_paths()` - /// though, this will be passed information about the request that triggered - /// the render. Errors here can be caused by either the server or the - /// client, so the user must specify an [`ErrorCause`]. This is also passed - /// the locale being rendered to. - #[cfg(not(target_arch = "wasm32"))] - pub async fn get_request_state( - &self, - info: StateGeneratorInfo<UnknownStateType>, - req: Request, - ) -> Result<TemplateState, ServerError> { - if let Some(get_request_state) = &self.get_request_state { - let res = get_request_state.call(info, req).await; - match res { - Ok(res) => Ok(res), - Err(GenericErrorWithCause { error, cause }) => Err(ServerError::RenderFnFailed { - fn_name: "get_request_state".to_string(), - template_name: self.get_path(), - cause, - source: error, - }), - } - } else { - Err(BuildError::TemplateFeatureNotEnabled { - template_name: self.path.clone(), - feature_name: "request_state".to_string(), - } - .into()) - } - } - /// Amalgamates given request and build states. Errors here can be caused by - /// either the server or the client, so the user must specify - /// an [`ErrorCause`]. - /// - /// This takes a separate build state and request state to ensure there are - /// no `None`s for either of the states. This will only be called if both - /// states are generated. - #[cfg(not(target_arch = "wasm32"))] - pub async fn amalgamate_states( - &self, - info: StateGeneratorInfo<UnknownStateType>, - build_state: TemplateState, - request_state: TemplateState, - ) -> Result<TemplateState, ServerError> { - if let Some(amalgamate_states) = &self.amalgamate_states { - let res = amalgamate_states - .call(info, build_state, request_state) - .await; - match res { - Ok(res) => Ok(res), - Err(GenericErrorWithCause { error, cause }) => Err(ServerError::RenderFnFailed { - fn_name: "amalgamate_states".to_string(), - template_name: self.get_path(), - cause, - source: error, - }), - } - } else { - Err(BuildError::TemplateFeatureNotEnabled { - template_name: self.path.clone(), - feature_name: "amalgamate_states".to_string(), - } - .into()) - } - } - /// Checks, by the user's custom logic, if this template should revalidate. - /// This function isn't presently parsed anything, but has - /// network access etc., and can really do whatever it likes. Errors here - /// can be caused by either the server or the client, so the - /// user must specify an [`ErrorCause`]. - #[cfg(not(target_arch = "wasm32"))] - pub async fn should_revalidate( - &self, - info: StateGeneratorInfo<UnknownStateType>, - req: Request, - ) -> Result<bool, ServerError> { - if let Some(should_revalidate) = &self.should_revalidate { - let res = should_revalidate.call(info, req).await; - match res { - Ok(res) => Ok(res), - Err(GenericErrorWithCause { error, cause }) => Err(ServerError::RenderFnFailed { - fn_name: "should_revalidate".to_string(), - template_name: self.get_path(), - cause, - source: error, - }), - } - } else { - Err(BuildError::TemplateFeatureNotEnabled { - template_name: self.path.clone(), - feature_name: "should_revalidate".to_string(), - } - .into()) - } - } - /// Gets the template's headers for the given state. These will be inserted - /// into any successful HTTP responses for this template, and they have - /// the power to override. - #[cfg(not(target_arch = "wasm32"))] - pub fn get_headers(&self, state: TemplateState) -> HeaderMap { - (self.set_headers)(state) - } - - // Value getters - /// Gets the path of the template. This is the root path under which any - /// generated pages will be served. In the simplest case, there will - /// only be one page rendered, and it will occupy that root position. - /// - /// Note that this will automatically transform `index` to an empty string. - pub fn get_path(&self) -> String { - if self.path == "index" { - String::new() - } else { - self.path.clone() - } - } - /// Gets the interval after which the template will next revalidate. - #[cfg(not(target_arch = "wasm32"))] - pub fn get_revalidate_interval(&self) -> Option<ComputedDuration> { - self.revalidate_after.clone() - } - - // Render characteristic checkers - /// Checks if this template can revalidate existing prerendered templates. - #[cfg(not(target_arch = "wasm32"))] - pub fn revalidates(&self) -> bool { - self.should_revalidate.is_some() || self.revalidate_after.is_some() - } - /// Checks if this template can revalidate existing prerendered templates - /// after a given time. - #[cfg(not(target_arch = "wasm32"))] - pub fn revalidates_with_time(&self) -> bool { - self.revalidate_after.is_some() - } - /// Checks if this template can revalidate existing prerendered templates - /// based on some given logic. - #[cfg(not(target_arch = "wasm32"))] - pub fn revalidates_with_logic(&self) -> bool { - self.should_revalidate.is_some() - } - /// Checks if this template can render more templates beyond those paths it - /// explicitly defines. - #[cfg(not(target_arch = "wasm32"))] - pub fn uses_incremental(&self) -> bool { - self.incremental_generation - } - /// Checks if this template is a template to generate paths beneath it. - #[cfg(not(target_arch = "wasm32"))] - pub fn uses_build_paths(&self) -> bool { - self.get_build_paths.is_some() - } - /// Checks if this template needs to do anything on requests for it. - #[cfg(not(target_arch = "wasm32"))] - pub fn uses_request_state(&self) -> bool { - self.get_request_state.is_some() - } - /// Checks if this template needs to do anything at build time. - #[cfg(not(target_arch = "wasm32"))] - pub fn uses_build_state(&self) -> bool { - self.get_build_state.is_some() - } - /// Checks if this template has custom logic to amalgamate build and - /// request states if both are generated. - #[cfg(not(target_arch = "wasm32"))] - pub fn can_amalgamate_states(&self) -> bool { - self.amalgamate_states.is_some() - } - /// Checks if this template defines no rendering logic whatsoever. Such - /// templates will be rendered using SSG. Basic templates can - /// still modify headers (which could hypothetically be using global state - /// that's dependent on server-side generation). - #[cfg(not(target_arch = "wasm32"))] - pub fn is_basic(&self) -> bool { - !self.uses_build_paths() - && !self.uses_build_state() - && !self.uses_request_state() - && !self.revalidates() - && !self.uses_incremental() - } - - // Builder setters - // The server-only ones have a different version for Wasm that takes in an empty - // function (this means we don't have to bring in function types, and therefore - // we can avoid bringing in the whole `http` module --- a very significant - // saving!) The macros handle the creation of empty functions to make user's - // lives easier - /// Sets the template rendering function to use, if the template takes - /// state. Templates that do not take state should use `.template()` - /// instead. - /// - /// The closure wrapping this performs will automatically handle suspense - /// state. - pub fn template_with_state<F, S, I>(mut self, val: F) -> Template<G> - where - F: Fn(Scope, I) -> View<G> + Send + Sync + 'static, - S: MakeRx<Rx = I> + Serialize + DeserializeOwned, - I: MakeUnrx<Unrx = S> + AnyFreeze + Clone + MakeRxRef, - // R: RxRef<RxNonRef = <S as MakeRx>::Rx> - { - self.template = Box::new(move |app_cx, mut route_manager, template_state, path| { - let state_empty = template_state.is_empty(); - // Declare a type on the untyped state (this doesn't perform any conversions, - // but the type we declare may be invalid) - let typed_state = template_state.change_type::<S>(); - - // Get an intermediate state type by checking against frozen state, active - // state, etc. - let intermediate_state = { - // Check if properties of the reactive type are already in the page state store - // If they are, we'll use them (so state persists for templates across the whole - // app) - let render_ctx = RenderCtx::from_ctx(app_cx); - // The render context will automatically handle prioritizing frozen or active - // state for us for this page as long as we have a reactive state type, which we - // do! - match render_ctx.get_active_or_frozen_page_state::<<S as MakeRx>::Rx>(&path) { - // If we navigated back to this page, and it's still in the PSS, the given state - // will be a dummy, but we don't need to worry because it's never checked if - // this evaluates - Some(existing_state) => existing_state, - // Again, frozen state has been dealt with already, so we'll fall back to - // generated state - None => { - // Make sure now that there is actually state - if state_empty { - // This will happen at build-time - panic!( - "the template for path `{}` takes state, but no state was found (you probably forgot to write a state generating function, like `get_build_state`)", - &path, - ); - } - - // Again, the render context can do the heavy lifting for us (this returns - // what we need, and can do type checking). The user - // really should have a generation function, but if they don't then they'd - // get a panic, so give them a nice error message. - // If this returns an error, we know the state was of the incorrect type - // (there is literally nothing we can do about this, and it's best to - // fail-fast and render nothing, hoping that this - // will appear at build-time) - match render_ctx - .register_page_state_value::<<S as MakeRx>::Rx>(&path, typed_state) - { - Ok(state) => state, - Err(err) => panic!( - "unrecoverable error in template state derivation: {:#?}", - err - ), - } - } - } - }; - - let disposer = ::sycamore::reactive::create_child_scope(app_cx, |child_cx| { - // Compute suspended states - #[cfg(target_arch = "wasm32")] - intermediate_state.compute_suspense(child_cx); - // let ref_struct = intermediate_state.to_ref_struct(child_cx); - let view = val(child_cx, intermediate_state); - route_manager.update_view(view); - }); - route_manager.update_disposer(disposer); - }); - self - } - /// Sets the template rendering function to use, if the template takes - /// unreactive state. - pub fn template_with_unreactive_state<F, S>(mut self, val: F) -> Template<G> - where - F: Fn(Scope, S) -> View<G> + Send + Sync + 'static, - S: MakeRx + Serialize + DeserializeOwned + UnreactiveState, - <S as MakeRx>::Rx: AnyFreeze + Clone + MakeUnrx<Unrx = S>, - { - self.template = Box::new(move |app_cx, mut route_manager, template_state, path| { - let state_empty = template_state.is_empty(); - // Declare a type on the untyped state (this doesn't perform any conversions, - // but the type we declare may be invalid) - let typed_state = template_state.change_type::<S>(); - - // Get an intermediate state type by checking against frozen state, active - // state, etc. - let intermediate_state = { - // Check if properties of the reactive type are already in the page state store - // If they are, we'll use them (so state persists for templates across the whole - // app) - let render_ctx = RenderCtx::from_ctx(app_cx); - // The render context will automatically handle prioritizing frozen or active - // state for us for this page as long as we have a reactive state type, which we - // do! - match render_ctx.get_active_or_frozen_page_state::<<S as MakeRx>::Rx>(&path) { - // If we navigated back to this page, and it's still in the PSS, the given state - // will be a dummy, but we don't need to worry because it's never checked if - // this evaluates - Some(existing_state) => existing_state, - // Again, frozen state has been dealt with already, so we'll fall back to - // generated state - None => { - // Make sure now that there is actually state - if state_empty { - // This will happen at build-time - panic!( - "the template for path `{}` takes state, but no state was found (you probably forgot to write a state generating function, like `get_build_state`)", - &path, - ); - } - - // Again, the render context can do the heavy lifting for us (this returns - // what we need, and can do type checking). The user - // really should have a generation function, but if they don't then they'd - // get a panic, so give them a nice error message. - // If this returns an error, we know the state was of the incorrect type - // (there is literally nothing we can do about this, and it's best to - // fail-fast and render nothing, hoping that this - // will appear at build-time) - match render_ctx - .register_page_state_value::<<S as MakeRx>::Rx>(&path, typed_state) - { - Ok(state) => state, - Err(err) => panic!( - "unrecoverable error in template state derivation: {:#?}", - err - ), - } - } - } - }; - - let disposer = ::sycamore::reactive::create_child_scope(app_cx, |child_cx| { - let view = val(child_cx, intermediate_state.make_unrx()); - route_manager.update_view(view); - }); - route_manager.update_disposer(disposer); - }); - self - } - - /// Sets the template rendering function to use for templates that take no - /// state. Templates that do take state should use - /// `.template_with_state()` instead. - pub fn template<F>(mut self, val: F) -> Template<G> - where - F: Fn(Scope) -> View<G> + Send + Sync + 'static, - { - self.template = Box::new(move |app_cx, mut route_manager, _template_state, path| { - // Declare that this page will never take any state to enable full caching - let render_ctx = RenderCtx::from_ctx(app_cx); - render_ctx.register_page_no_state(&path); - - let disposer = ::sycamore::reactive::create_child_scope(app_cx, |child_cx| { - let view = val(child_cx); - route_manager.update_view(view); - }); - route_manager.update_disposer(disposer); - }); - self - } - - /// Sets the document `<head>` rendering function to use. The [`View`] - /// produced by this will only be rendered on the engine-side, and will - /// *not* be reactive (since it only contains metadata). - /// - /// This is for heads that do require state. Those that do not should use - /// `.head()` instead. - #[cfg(not(target_arch = "wasm32"))] - pub fn head_with_state<S>( - mut self, - val: impl Fn(Scope, S) -> View<SsrNode> + Send + Sync + 'static, - ) -> Template<G> - where - S: Serialize + DeserializeOwned + MakeRx + 'static, - { - let template_name = self.get_path(); - self.head = Box::new(move |cx, template_state| { - // Make sure now that there is actually state - if template_state.is_empty() { - // This will happen at build-time - panic!( - "the template '{}' takes state, but no state was found (you probably forgot to write a state generating function, like `get_build_state`)", - &template_name, - ); - } - // Declare a type on the untyped state (this doesn't perform any conversions, - // but the type we declare may be invalid) - let typed_state = template_state.change_type::<S>(); - - let state = match typed_state.to_concrete() { - Ok(state) => state, - Err(err) => panic!( - "unrecoverable error in template state derivation: {:#?}", - err - ), - }; - val(cx, state) - }); - self - } - /// Sets the document `<head>` rendering function to use. The [`View`] - /// produced by this will only be rendered on the engine-side, and will - /// *not* be reactive (since it only contains metadata). - /// - /// This is for heads that do not require state. Those that do should use - /// `.head_with_state()` instead. - #[cfg(not(target_arch = "wasm32"))] - pub fn head( - mut self, - val: impl Fn(Scope) -> View<SsrNode> + Send + Sync + 'static, - ) -> Template<G> { - self.head = Box::new(move |cx, _template_state| val(cx)); - self - } - /// Sets the document `<head>` rendering function to use. The [`View`] - /// produced by this will only be rendered on the engine-side, and will - /// *not* be reactive (since it only contains metadata). - /// - /// This is for heads that do not require state. Those that do should use - /// `.head_with_state()` instead. - #[cfg(target_arch = "wasm32")] - pub fn head(self, _val: impl Fn() + 'static) -> Template<G> { - self - } - /// Sets the document `<head>` rendering function to use. The [`View`] - /// produced by this will only be rendered on the engine-side, and will - /// *not* be reactive (since it only contains metadata). - /// - /// This is for heads that do require state. Those that do not should use - /// `.head()` instead. - #[cfg(target_arch = "wasm32")] - pub fn head_with_state(self, _val: impl Fn() + 'static) -> Self { - self - } - - /// Sets the function to set headers. This will override Perseus' inbuilt - /// header defaults. - #[cfg(not(target_arch = "wasm32"))] - pub fn set_headers_fn<S>( - mut self, - val: impl Fn(S) -> HeaderMap + Send + Sync + 'static, - ) -> Template<G> - where - S: Serialize + DeserializeOwned + MakeRx + 'static, - { - let template_name = self.get_path(); - self.set_headers = Box::new(move |template_state| { - // Make sure now that there is actually state - if template_state.is_empty() { - // This will happen at build-time - panic!( - "the template '{}' takes state, but no state was found (you probably forgot to write a state generating function, like `get_build_state`)", - &template_name, - ); - } - // Declare a type on the untyped state (this doesn't perform any conversions, - // but the type we declare may be invalid) - let typed_state = template_state.change_type::<S>(); - - let state = match typed_state.to_concrete() { - Ok(state) => state, - Err(err) => panic!( - "unrecoverable error in template state derivation: {:#?}", - err - ), - }; - val(state) - }); - self - } - /// Sets the function to set headers. This will override Perseus' inbuilt - /// header defaults. - #[cfg(target_arch = "wasm32")] - pub fn set_headers_fn(self, _val: impl Fn() + 'static) -> Template<G> { - self - } - - /// Enables the *build paths* strategy with the given function. - #[cfg(not(target_arch = "wasm32"))] - pub fn build_paths_fn( - mut self, - val: impl GetBuildPathsFnType + Send + Sync + 'static, - ) -> Template<G> { - self.get_build_paths = Some(Box::new(val)); - self - } - /// Enables the *build paths* strategy with the given function. - #[cfg(target_arch = "wasm32")] - pub fn build_paths_fn(self, _val: impl Fn() + 'static) -> Template<G> { - self - } - - /// Enables the *incremental generation* strategy. - #[cfg(not(target_arch = "wasm32"))] - pub fn incremental_generation(mut self) -> Template<G> { - self.incremental_generation = true; - self - } - /// Enables the *incremental generation* strategy. - #[cfg(target_arch = "wasm32")] - pub fn incremental_generation(self) -> Template<G> { - self - } - - /// Enables the *build state* strategy with the given function. - #[cfg(not(target_arch = "wasm32"))] - pub fn build_state_fn<S, B>( - mut self, - val: impl GetBuildStateUserFnType<S, B> + Clone + Send + Sync + 'static, - ) -> Template<G> - where - S: Serialize + DeserializeOwned + MakeRx, - B: Serialize + DeserializeOwned + Send + Sync + 'static, - { - self.get_build_state = Some(Box::new( - move |info: StateGeneratorInfo<UnknownStateType>| { - let val = val.clone(); - async move { - let user_info = info.change_type::<B>(); - let user_state = val.call(user_info).await?; - let template_state: TemplateState = user_state.into(); - Ok(template_state) - } - }, - )); - self - } - /// Enables the *build state* strategy with the given function. - #[cfg(target_arch = "wasm32")] - pub fn build_state_fn(self, _val: impl Fn() + 'static) -> Template<G> { - self - } - - /// Enables the *request state* strategy with the given function. - #[cfg(not(target_arch = "wasm32"))] - pub fn request_state_fn<S, B>( - mut self, - val: impl GetRequestStateUserFnType<S, B> + Clone + Send + Sync + 'static, - ) -> Template<G> - where - S: Serialize + DeserializeOwned + MakeRx, - B: Serialize + DeserializeOwned + Send + Sync + 'static, - { - self.get_request_state = Some(Box::new( - move |info: StateGeneratorInfo<UnknownStateType>, req| { - let val = val.clone(); - async move { - let user_info = info.change_type::<B>(); - let user_state = val.call(user_info, req).await?; - let template_state: TemplateState = user_state.into(); - Ok(template_state) - } - }, - )); - self - } - /// Enables the *request state* strategy with the given function. - #[cfg(target_arch = "wasm32")] - pub fn request_state_fn(self, _val: impl Fn() + 'static) -> Template<G> { - self - } - - /// Enables the *revalidation* strategy (logic variant) with the given - /// function. - #[cfg(not(target_arch = "wasm32"))] - pub fn should_revalidate_fn<B>( - mut self, - val: impl ShouldRevalidateUserFnType<B> + Clone + Send + Sync + 'static, - ) -> Template<G> - where - B: Serialize + DeserializeOwned + Send + Sync + 'static, - { - self.should_revalidate = Some(Box::new( - move |info: StateGeneratorInfo<UnknownStateType>, req| { - let val = val.clone(); - async move { - let user_info = info.change_type::<B>(); - val.call(user_info, req).await - } - }, - )); - self - } - /// Enables the *revalidation* strategy (logic variant) with the given - /// function. - #[cfg(target_arch = "wasm32")] - pub fn should_revalidate_fn(self, _val: impl Fn() + 'static) -> Template<G> { - self - } - - /// Enables the *revalidation* strategy (time variant). This takes a time - /// string of a form like `1w` for one week. - /// - /// - s: second, - /// - m: minute, - /// - h: hour, - /// - d: day, - /// - w: week, - /// - M: month (30 days used here, 12M ≠ 1y!), - /// - y: year (365 days always, leap years ignored, if you want them add - /// them as days) - #[cfg(not(target_arch = "wasm32"))] - pub fn revalidate_after<I: PerseusDuration>(mut self, val: I) -> Template<G> { - let computed_duration = match val.into_computed() { - Ok(val) => val, - // This is fine, because this will be checked when we try to build the app (i.e. it'll - // show up before runtime) - Err(_) => panic!("invalid revalidation interval"), - }; - self.revalidate_after = Some(computed_duration); - self - } - /// Enables the *revalidation* strategy (time variant). This takes a time - /// string of a form like `1w` for one week. - /// - /// - s: second, - /// - m: minute, - /// - h: hour, - /// - d: day, - /// - w: week, - /// - M: month (30 days used here, 12M ≠ 1y!), - /// - y: year (365 days always, leap years ignored, if you want them add - /// them as days) - #[cfg(target_arch = "wasm32")] - pub fn revalidate_after<I: PerseusDuration>(self, _val: I) -> Template<G> { - self - } - - /// Enables state amalgamation with the given function. State amalgamation - /// allows you to have one template generate state at both build time - /// and request time. The function you provide here is responsible for - /// rationalizing the two into one single state to be sent to the client, - /// and this will be run just after the request state function - /// completes. See [`States`] for further details. - #[cfg(not(target_arch = "wasm32"))] - pub fn amalgamate_states_fn<S, B>( - mut self, - val: impl AmalgamateStatesUserFnType<S, B> + Clone + Send + Sync + 'static, - ) -> Template<G> - where - S: Serialize + DeserializeOwned + MakeRx + Send + Sync + 'static, - B: Serialize + DeserializeOwned + Send + Sync + 'static, - { - self.amalgamate_states = Some(Box::new( - move |info: StateGeneratorInfo<UnknownStateType>, - build_state: TemplateState, - request_state: TemplateState| { - let val = val.clone(); - async move { - // Amalgamation logic will only be called if both states are indeed defined - let typed_build_state = build_state.change_type::<S>(); - let user_build_state = match typed_build_state.to_concrete() { - Ok(state) => state, - Err(err) => panic!( - "unrecoverable error in state amalgamation parameter derivation: {:#?}", - err - ), - }; - let typed_request_state = request_state.change_type::<S>(); - let user_request_state = match typed_request_state.to_concrete() { - Ok(state) => state, - Err(err) => panic!( - "unrecoverable error in state amalgamation parameter derivation: {:#?}", - err - ), - }; - let user_info = info.change_type::<B>(); - let user_state = val - .call(user_info, user_build_state, user_request_state) - .await?; - let template_state: TemplateState = user_state.into(); - Ok(template_state) - } - }, - )); - self - } - /// Enables state amalgamation with the given function. State amalgamation - /// allows you to have one template generate state at both build time - /// and request time. The function you provide here is responsible for - /// rationalizing the two into one single state to be sent to the client, - /// and this will be run just after the request state function - /// completes. See [`States`] for further details. - #[cfg(target_arch = "wasm32")] - pub fn amalgamate_states_fn(self, _val: impl Fn() + 'static) -> Template<G> { - self - } -} - -// The engine needs to know whether or not to use hydration, this is how we pass -// those feature settings through -#[cfg(not(feature = "hydrate"))] -#[doc(hidden)] -pub(crate) type TemplateNodeType = sycamore::prelude::DomNode; -#[cfg(feature = "hydrate")] -#[doc(hidden)] -pub(crate) type TemplateNodeType = sycamore::prelude::HydrateNode; - -/// Checks if we're on the server or the client. This must be run inside a -/// reactive scope (e.g. a `view!` or `create_effect`), because it uses -/// Sycamore context. -// TODO (0.4.0) Remove this altogether -#[macro_export] -#[deprecated(since = "0.3.1", note = "use `G::IS_BROWSER` instead")] -macro_rules! is_server { - () => {{ - let render_ctx = ::sycamore::context::use_context::<::perseus::templates::RenderCtx>(); - render_ctx.is_server - }}; -} diff --git a/packages/perseus/src/template/core/entity.rs b/packages/perseus/src/template/core/entity.rs new file mode 100644 index 0000000000..857294e381 --- /dev/null +++ b/packages/perseus/src/template/core/entity.rs @@ -0,0 +1,75 @@ +use std::{collections::HashMap, ops::Deref}; + +use sycamore::web::Html; + +use super::TemplateInner; + +/// An internal container over a [`TemplateInner`]. Conceptually, +/// this represents *either* a template or a capsule within Perseus. Both +/// [`Template`] and [`Capsule`] simply wrap this with their own unique methods. +/// +/// You can determine if this is a capsule or not by checking the underlying +/// `is_capsule` property. +/// +/// # Capsule specifics +/// +/// Although this functionally represents either a template or a capsule, there +/// are some parts of capsule functionality that are only accessible through the +/// `Capsule` type itself, such as fallback views and properties. This is fine, +/// however, as capsules are used by calling a component method on them, meaning +/// the widget rendering process always has access to the capsule itself. +#[derive(Debug)] +pub struct Entity<G: Html>(TemplateInner<G>); + +impl<G: Html> From<TemplateInner<G>> for Entity<G> { + fn from(val: TemplateInner<G>) -> Self { + Self(val) + } +} + +// Immutable methods should be able to be called such that this can be treated +// as a template/capsule +impl<G: Html> std::ops::Deref for Entity<G> { + type Target = TemplateInner<G>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// An alias for a map of entities, keyed by their names/root paths. +pub type EntityMap<G> = HashMap<String, Forever<Entity<G>>>; + +/// A helpful wrapper type that allows something to be stored as either an owned +/// type or a static reference, which prevents unnecessary memory leaks when +/// handling user-provided templates and capsules, maintaining compatibility +/// between the static and function definition patterns (if that makes no sense, +/// see the book). +/// +/// This is named `Forever` because it guarantees that what it holds is +/// accessible for the lifetime of the holder, no matter what that is. +#[derive(Debug)] +pub enum Forever<T: 'static> { + Owned(T), + StaticRef(&'static T), +} +impl<T: 'static> Deref for Forever<T> { + type Target = T; + fn deref(&self) -> &Self::Target { + match &self { + Self::Owned(val) => &val, + Self::StaticRef(val) => val, + } + } +} +impl<T: 'static> From<T> for Forever<T> { + fn from(val: T) -> Self { + Self::Owned(val) + } +} +impl<T: 'static> From<&'static T> for Forever<T> { + fn from(val: &'static T) -> Self { + Self::StaticRef(val) + } +} +// Convenience conversions to reduce the burden in `init.rs` diff --git a/packages/perseus/src/template/core/getters.rs b/packages/perseus/src/template/core/getters.rs new file mode 100644 index 0000000000..2f7b5520e2 --- /dev/null +++ b/packages/perseus/src/template/core/getters.rs @@ -0,0 +1,89 @@ +use super::TemplateInner; +#[cfg(not(target_arch = "wasm32"))] +use crate::utils::ComputedDuration; +use sycamore::web::Html; + +impl<G: Html> TemplateInner<G> { + /// Gets the path of the template. This is the root path under which any + /// generated pages will be served. In the simplest case, there will + /// only be one page rendered, and it will occupy that root position. + /// + /// Note that this will automatically transform `index` to an empty string. + /// + /// Note that this will prepend `__capsule/` to any capsules automatically. + pub fn get_path(&self) -> String { + let base = if self.path == "index" { + String::new() + } else { + self.path.clone() + }; + if self.is_capsule { + format!("__capsule/{}", base) + } else { + base + } + } + /// Gets the interval after which the template will next revalidate. + #[cfg(not(target_arch = "wasm32"))] + pub fn get_revalidate_interval(&self) -> Option<ComputedDuration> { + self.revalidate_after.clone() + } + + // Render characteristic checkers + /// Checks if this template can revalidate existing prerendered templates. + #[cfg(not(target_arch = "wasm32"))] + pub fn revalidates(&self) -> bool { + self.should_revalidate.is_some() || self.revalidate_after.is_some() + } + /// Checks if this template can revalidate existing prerendered templates + /// after a given time. + #[cfg(not(target_arch = "wasm32"))] + pub fn revalidates_with_time(&self) -> bool { + self.revalidate_after.is_some() + } + /// Checks if this template can revalidate existing prerendered templates + /// based on some given logic. + #[cfg(not(target_arch = "wasm32"))] + pub fn revalidates_with_logic(&self) -> bool { + self.should_revalidate.is_some() + } + /// Checks if this template can render more templates beyond those paths it + /// explicitly defines. + #[cfg(not(target_arch = "wasm32"))] + pub fn uses_incremental(&self) -> bool { + self.incremental_generation + } + /// Checks if this template is a template to generate paths beneath it. + #[cfg(not(target_arch = "wasm32"))] + pub fn uses_build_paths(&self) -> bool { + self.get_build_paths.is_some() + } + /// Checks if this template needs to do anything on requests for it. + #[cfg(not(target_arch = "wasm32"))] + pub fn uses_request_state(&self) -> bool { + self.get_request_state.is_some() + } + /// Checks if this template needs to do anything at build time. + #[cfg(not(target_arch = "wasm32"))] + pub fn uses_build_state(&self) -> bool { + self.get_build_state.is_some() + } + /// Checks if this template has custom logic to amalgamate build and + /// request states if both are generated. + #[cfg(not(target_arch = "wasm32"))] + pub fn can_amalgamate_states(&self) -> bool { + self.amalgamate_states.is_some() + } + /// Checks if this template defines no rendering logic whatsoever. Such + /// templates will be rendered using SSG. Basic templates can + /// still modify headers (which could hypothetically be using global state + /// that's dependent on server-side generation). + #[cfg(not(target_arch = "wasm32"))] + pub fn is_basic(&self) -> bool { + !self.uses_build_paths() + && !self.uses_build_state() + && !self.uses_request_state() + && !self.revalidates() + && !self.uses_incremental() + } +} diff --git a/packages/perseus/src/template/core/mod.rs b/packages/perseus/src/template/core/mod.rs new file mode 100644 index 0000000000..cee231fc82 --- /dev/null +++ b/packages/perseus/src/template/core/mod.rs @@ -0,0 +1,215 @@ +// This module contains the primary shared logic in Perseus, and is broken up to +// avoid a 2000-line file. + +mod getters; +mod renderers; +mod setters; +mod utils; +// These are broken out because of state-management closure wrapping +mod entity; +mod state_setters; + +use std::ops::Deref; + +pub(crate) use entity::{Entity, EntityMap, Forever}; +pub(crate) use utils::*; + +#[cfg(not(target_arch = "wasm32"))] +use super::fn_types::*; +use super::TemplateFn; +#[cfg(not(target_arch = "wasm32"))] +use crate::utils::ComputedDuration; +use sycamore::{prelude::create_scope, view::View, web::Html}; + +/// A single template in an app. Each template is comprised of a Sycamore view, +/// a state type, and some functions involved with generating that state. Pages +/// can then be generated from particular states. For instance, a single `docs` +/// template could have a state `struct` that stores a title and some content, +/// which could then render as many pages as desired. +/// +/// You can read more about the templates system [here](https://arctic-hen7.github.io/perseus/en-US/docs/next/core-principles). +#[derive(Debug)] +pub struct Template<G: Html> { + /// The inner entity. + pub(crate) inner: Entity<G>, +} +impl<G: Html> Deref for Template<G> { + type Target = TemplateInner<G>; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} +impl<G: Html> Template<G> { + /// Creates a new [`TemplateInner`] (a builder for [`Template`]s). By + /// default, this has absolutely no associated data, and, if rendered, + /// it would result in a blank screen. You can call methods like + /// `.view()` on this, and you should eventually call `.build()` to turn + /// it into a full template. + pub fn build(path: &str) -> TemplateInner<G> { + TemplateInner::new(path) + } +} + +/// The internal representation of a Perseus template, with all the methods +/// involved in creating and managing it. As this `struct` is not `Clone`, +/// it will almost always appear wrapped in a full [`Template`], which allows +/// cloning and passing the template around arbitrarily. As that dereferences +/// to this, you will be able to use any of the methods on this `struct` on +/// [`Template`]. +pub struct TemplateInner<G: Html> { + /// The path to the root of the template. Any build paths will be inserted + /// under this. + path: String, + /// A function that will render your template. This will be provided the + /// rendered properties, and will be used whenever your template needs + /// to be prerendered in some way. This should be very similar to the + /// function that hydrates your template on the client side. + /// This will be executed inside `sycamore::render_to_string`, and should + /// return a `Template<SsrNode>`. This takes an `Option<Props>` + /// because otherwise efficient typing is almost impossible for templates + /// without any properties (solutions welcome in PRs!). + // Public to the crate so capsules can shadow these functions for property support + pub(crate) view: TemplateFn<G>, + /// A function that will be used to populate the document's `<head>` with + /// metadata such as the title. This will be passed state in + /// the same way as `template`, but will always be rendered to a string, + /// which will then be interpolated directly into the `<head>`, + /// so reactivity here will not work! + #[cfg(not(target_arch = "wasm32"))] + pub(crate) head: Option<HeadFn>, + /// A function to be run when the server returns an HTTP response. This + /// should return headers for said response, given the template's state. + /// The most common use-case of this is to add cache control that respects + /// revalidation. This will only be run on successful responses, and + /// does have the power to override existing headers. By default, this will + /// create sensible cache control headers. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) set_headers: Option<SetHeadersFn>, + /// A function that generates the information to begin building a template. + /// This is responsible for generating all the paths that will built for + /// that template at build-time (which may later be extended with + /// incremental generation), along with the generation of any extra + /// state that may be collectively shared by other state generating + /// functions. + #[cfg(not(target_arch = "wasm32"))] + get_build_paths: Option<GetBuildPathsFn>, + /// Defines whether or not any new paths that match this template will be + /// prerendered and cached in production. This allows you to + /// have potentially billions of templates and retain a super-fast build + /// process. The first user will have an ever-so-slightly slower + /// experience, and everyone else gets the benefits afterwards. This + /// requires `get_build_paths`. Note that the template root will NOT + /// be rendered on demand, and must be explicitly defined if it's wanted. It + /// can use a different template. + #[cfg(not(target_arch = "wasm32"))] + incremental_generation: bool, + /// A function that gets the initial state to use to prerender the template + /// at build time. This will be passed the path of the template, and + /// will be run for any sub-paths. + #[cfg(not(target_arch = "wasm32"))] + get_build_state: Option<GetBuildStateFn>, + /// A function that will run on every request to generate a state for that + /// request. This allows server-side-rendering. This can be used with + /// `get_build_state`, though custom amalgamation logic must be provided. + #[cfg(not(target_arch = "wasm32"))] + get_request_state: Option<GetRequestStateFn>, + /// A function to be run on every request to check if a template prerendered + /// at build-time should be prerendered again. If used with + /// `revalidate_after`, this function will only be run after that time + /// period. This function will not be parsed anything specific to the + /// request that invoked it. + #[cfg(not(target_arch = "wasm32"))] + should_revalidate: Option<ShouldRevalidateFn>, + /// A length of time after which to prerender the template again. The given + /// duration will be waited for, and the next request after it will lead + /// to a revalidation. Note that, if this is used with incremental + /// generation, the counter will only start after the first render + /// (meaning if you expect a weekly re-rendering cycle for all pages, + /// they'd likely all be out of sync, you'd need to manually implement + /// that with `should_revalidate`). + #[cfg(not(target_arch = "wasm32"))] + revalidate_after: Option<ComputedDuration>, + /// Custom logic to amalgamate potentially different states generated at + /// build and request time. This is only necessary if your template uses + /// both `build_state` and `request_state`. If not specified and both are + /// generated, request state will be prioritized. + #[cfg(not(target_arch = "wasm32"))] + amalgamate_states: Option<AmalgamateStatesFn>, + /// Whether or not this template is actually a capsule. This impacts + /// significant aspects of internal handling. + /// + /// There is absolutely no circumstance in which you should ever change + /// this. Ever. You will break your app. Always. + pub is_capsule: bool, + /// Whether or not this template's pages can have their builds rescheduled + /// from build-time to request-time if they depend on capsules that aren't + /// ready with state at build-time. This is included as a precaution to + /// seemingly erroneous performance changes with pages. If rescheduling + /// is needed and it hasn't been explicitly allowed, an error will be + /// returned from the build process. + pub(crate) can_be_rescheduled: bool, +} +impl<G: Html> std::fmt::Debug for TemplateInner<G> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Template") + .field("path", &self.path) + .field("is_capsule", &self.is_capsule) + .finish() + } +} +impl<G: Html> TemplateInner<G> { + /// An internal creator for new inner templates. This is wrapped by + /// `Template::build` and `Capsule::build`. + fn new(path: impl Into<String> + std::fmt::Display) -> Self { + Self { + path: path.to_string(), + // Because of the scope disposer return type, this isn't as trivial as an empty function + view: Box::new(|_, _, _, _| Ok((View::empty(), create_scope(|_| {})))), + // Unlike `template`, this may not be set at all (especially in very simple apps) + #[cfg(not(target_arch = "wasm32"))] + head: None, + #[cfg(not(target_arch = "wasm32"))] + set_headers: None, + #[cfg(not(target_arch = "wasm32"))] + get_build_paths: None, + #[cfg(not(target_arch = "wasm32"))] + incremental_generation: false, + #[cfg(not(target_arch = "wasm32"))] + get_build_state: None, + #[cfg(not(target_arch = "wasm32"))] + get_request_state: None, + #[cfg(not(target_arch = "wasm32"))] + should_revalidate: None, + #[cfg(not(target_arch = "wasm32"))] + revalidate_after: None, + #[cfg(not(target_arch = "wasm32"))] + amalgamate_states: None, + // There is no mechanism to set this to `true`, except through the `Capsule` struct + is_capsule: false, + can_be_rescheduled: false, + } + } + /// Builds a full [`Template`] from this [`TemplateInner`], consuming it in + /// the process. Once called, the template cannot be modified anymore, + /// and it will be placed into a smart pointer, allowing it to be cloned + /// freely with minimal costs. + /// + /// You should call this just before you return your template. + pub fn build(self) -> Template<G> { + Template { + inner: Entity::from(self), + } + } +} + +// The engine needs to know whether or not to use hydration, this is how we pass +// those feature settings through +/// An alias for `DomNode` or `HydrateNode`, depending on the feature flags +/// enabled. +#[cfg(all(not(feature = "hydrate"), target_arch = "wasm32"))] +pub(crate) type BrowserNodeType = sycamore::prelude::DomNode; +/// An alias for `DomNode` or `HydrateNode`, depending on the feature flags +/// enabled. +#[cfg(all(feature = "hydrate", target_arch = "wasm32"))] +pub(crate) type BrowserNodeType = sycamore::prelude::HydrateNode; diff --git a/packages/perseus/src/template/core/renderers.rs b/packages/perseus/src/template/core/renderers.rs new file mode 100644 index 0000000000..89a1ffe758 --- /dev/null +++ b/packages/perseus/src/template/core/renderers.rs @@ -0,0 +1,256 @@ +use super::utils::PreloadInfo; +use crate::errors::*; +#[cfg(not(target_arch = "wasm32"))] +use crate::i18n::Translator; +use crate::path::PathMaybeWithLocale; +#[cfg(not(target_arch = "wasm32"))] +use crate::reactor::Reactor; +#[cfg(not(target_arch = "wasm32"))] +use crate::reactor::RenderMode; +use crate::state::TemplateState; +#[cfg(not(target_arch = "wasm32"))] +use crate::state::{BuildPaths, StateGeneratorInfo, UnknownStateType}; +#[cfg(not(target_arch = "wasm32"))] +use crate::template::default_headers; +use crate::template::TemplateInner; +#[cfg(not(target_arch = "wasm32"))] +use crate::Request; +#[cfg(not(target_arch = "wasm32"))] +use http::HeaderMap; +#[cfg(target_arch = "wasm32")] +use sycamore::prelude::ScopeDisposer; +use sycamore::web::Html; +#[cfg(not(target_arch = "wasm32"))] +use sycamore::web::SsrNode; +use sycamore::{prelude::Scope, view::View}; + +impl<G: Html> TemplateInner<G> { + /// Executes the user-given function that renders the template on the + /// client-side ONLY. This takes in an existing global state. + /// + /// This should NOT be used to render widgets! + #[cfg(target_arch = "wasm32")] + #[allow(clippy::too_many_arguments)] + pub(crate) fn render_for_template_client<'a>( + &self, + path: PathMaybeWithLocale, + state: TemplateState, + cx: Scope<'a>, + ) -> Result<(View<G>, ScopeDisposer<'a>), ClientError> { + assert!( + !self.is_capsule, + "tried to render capsule with template logic" + ); + + // Only widgets use the preload info + (self.view)( + cx, + PreloadInfo { + locale: String::new(), + was_incremental_match: false, + }, + state, + path, + ) + } + /// Executes the user-given function that renders the template on the + /// server-side ONLY. This automatically initializes an isolated global + /// state. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn render_for_template_server( + &self, + path: PathMaybeWithLocale, + state: TemplateState, + global_state: TemplateState, + mode: RenderMode<SsrNode>, + cx: Scope, + translator: &Translator, + ) -> Result<View<G>, ClientError> { + assert!( + !self.is_capsule, + "tried to render capsule with template logic" + ); + + // The context we have here has no context elements set on it, so we set all the + // defaults (job of the router component on the client-side) + // We don't need the value, we just want the context instantiations + Reactor::engine(global_state, mode, Some(translator)).add_self_to_cx(cx); + // This is used for widget preloading, which doesn't occur on the engine-side + let preload_info = PreloadInfo {}; + // We don't care about the scope disposer, since this scope is unique anyway + let (view, _) = (self.view)(cx, preload_info, state, path)?; + Ok(view) + } + /// Executes the user-given function that renders the document `<head>`, + /// 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. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn render_head_str( + &self, + state: TemplateState, + global_state: TemplateState, + translator: &Translator, + ) -> Result<String, ServerError> { + use sycamore::{ + prelude::create_scope_immediate, utils::hydrate::with_no_hydration_context, + }; + + // This is a bit roundabout for error handling + let mut prerender_view = Ok(View::empty()); + create_scope_immediate(|cx| { + // The context we have here has no context elements set on it, so we set all the + // defaults (job of the router component on the client-side) + // We don't need the value, we just want the context instantiations + // We don't need any page state store here + Reactor::<G>::engine(global_state, RenderMode::Head, Some(translator)) + .add_self_to_cx(cx); + + prerender_view = with_no_hydration_context(|| { + if let Some(head_fn) = &self.head { + (head_fn)(cx, state) + } else { + Ok(View::empty()) + } + }); + }); + let prerender_view = prerender_view?; + let prerendered = sycamore::render_to_string(|_| prerender_view); + + Ok(prerendered) + } + /// Gets the list of templates that should be prerendered for at build-time. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn get_build_paths(&self) -> Result<BuildPaths, ServerError> { + if let Some(get_build_paths) = &self.get_build_paths { + get_build_paths.call().await + } else { + Err(BuildError::TemplateFeatureNotEnabled { + template_name: self.path.clone(), + feature_name: "build_paths".to_string(), + } + .into()) + } + } + /// Gets the initial state for a template. This needs to be passed the full + /// path of the template, which may be one of those generated by + /// `.get_build_paths()`. This also needs the locale being rendered to so + /// that more complex applications like custom documentation systems can + /// be enabled. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn get_build_state( + &self, + info: StateGeneratorInfo<UnknownStateType>, + ) -> Result<TemplateState, ServerError> { + if let Some(get_build_state) = &self.get_build_state { + get_build_state.call(info).await + } else { + Err(BuildError::TemplateFeatureNotEnabled { + template_name: self.path.clone(), + feature_name: "build_state".to_string(), + } + .into()) + } + } + /// Gets the request-time state for a template. This is equivalent to SSR, + /// and will not be performed at build-time. Unlike `.get_build_paths()` + /// though, this will be passed information about the request that triggered + /// the render. Errors here can be caused by either the server or the + /// client, so the user must specify an [`ErrorBlame`]. This is also passed + /// the locale being rendered to. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn get_request_state( + &self, + info: StateGeneratorInfo<UnknownStateType>, + req: Request, + ) -> Result<TemplateState, ServerError> { + if let Some(get_request_state) = &self.get_request_state { + get_request_state.call(info, req).await + } else { + Err(BuildError::TemplateFeatureNotEnabled { + template_name: self.path.clone(), + feature_name: "request_state".to_string(), + } + .into()) + } + } + /// Amalgamates given request and build states. Errors here can be caused by + /// either the server or the client, so the user must specify + /// an [`ErrorBlame`]. + /// + /// This takes a separate build state and request state to ensure there are + /// no `None`s for either of the states. This will only be called if both + /// states are generated. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn amalgamate_states( + &self, + info: StateGeneratorInfo<UnknownStateType>, + build_state: TemplateState, + request_state: TemplateState, + ) -> Result<TemplateState, ServerError> { + if let Some(amalgamate_states) = &self.amalgamate_states { + amalgamate_states + .call(info, build_state, request_state) + .await + } else { + Err(BuildError::TemplateFeatureNotEnabled { + template_name: self.path.clone(), + feature_name: "amalgamate_states".to_string(), + } + .into()) + } + } + /// Checks, by the user's custom logic, if this template should revalidate. + /// This function isn't presently parsed anything, but has + /// network access etc., and can really do whatever it likes. Errors here + /// can be caused by either the server or the client, so the + /// user must specify an [`ErrorBlame`]. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn should_revalidate( + &self, + info: StateGeneratorInfo<UnknownStateType>, + req: Request, + ) -> Result<bool, ServerError> { + if let Some(should_revalidate) = &self.should_revalidate { + should_revalidate.call(info, req).await + } else { + Err(BuildError::TemplateFeatureNotEnabled { + template_name: self.path.clone(), + feature_name: "should_revalidate".to_string(), + } + .into()) + } + } + /// Gets the template's headers for the given state. These will be inserted + /// into any successful HTTP responses for this template, and they have + /// the power to override existing headers, including `Content-Type`. + /// + /// This will automatically instantiate a scope and set up an engine-side + /// reactor so that the user's function can access global state and + /// translations, as localized headers are very much real. Locale + /// detection pages are considered internal to Perseus, and therefore do + /// not have support for user headers (at this time). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn get_headers( + &self, + state: TemplateState, + global_state: TemplateState, + translator: Option<&Translator>, + ) -> Result<HeaderMap, ServerError> { + use sycamore::prelude::create_scope_immediate; + + let mut res = Ok(HeaderMap::new()); + create_scope_immediate(|cx| { + let reactor = Reactor::<G>::engine(global_state, RenderMode::Headers, translator); + reactor.add_self_to_cx(cx); + + if let Some(header_fn) = &self.set_headers { + res = (header_fn)(cx, state); + } else { + res = Ok(default_headers()); + } + }); + + res + } +} diff --git a/packages/perseus/src/template/core/setters.rs b/packages/perseus/src/template/core/setters.rs new file mode 100644 index 0000000000..7e20dd7a25 --- /dev/null +++ b/packages/perseus/src/template/core/setters.rs @@ -0,0 +1,342 @@ +use super::TemplateInner; +use crate::utils::PerseusDuration; +use sycamore::web::Html; + +// This file is all engine-side functions, and browser-side dummies +#[cfg(not(target_arch = "wasm32"))] +use super::super::fn_types::*; +#[cfg(not(target_arch = "wasm32"))] +use crate::state::{BuildPaths, MakeRx}; +#[cfg(not(target_arch = "wasm32"))] +use crate::state::{StateGeneratorInfo, TemplateState, UnknownStateType}; +#[cfg(not(target_arch = "wasm32"))] +use http::HeaderMap; +#[cfg(not(target_arch = "wasm32"))] +use serde::{de::DeserializeOwned, Serialize}; +#[cfg(not(target_arch = "wasm32"))] +use sycamore::{prelude::Scope, view::View, web::SsrNode}; + +impl<G: Html> TemplateInner<G> { + // The server-only ones have a different version for Wasm that takes in an empty + // function (this means we don't have to bring in function types, and therefore + // we can avoid bringing in the whole `http` module --- a very significant + // saving!) The macros handle the creation of empty functions to make user's + // lives easier + /// Sets the document `<head>` rendering function to use. The [`View`] + /// produced by this will only be rendered on the engine-side, and will + /// *not* be reactive (since it only contains metadata). + /// + /// This is for heads that do not require state. Those that do should use + /// `.head_with_state()` instead. + #[cfg(not(target_arch = "wasm32"))] + pub fn head<V: Into<GeneratorResult<View<SsrNode>>>>( + mut self, + val: impl Fn(Scope) -> V + Send + Sync + 'static, + ) -> Self { + let template_name = self.get_path(); + self.head = Some(Box::new(move |cx, _template_state| { + let template_name = template_name.clone(); + val(cx).into().into_server_result("head", template_name) + })); + self + } + /// Sets the document `<head>` rendering function to use. The [`View`] + /// produced by this will only be rendered on the engine-side, and will + /// *not* be reactive (since it only contains metadata). + /// + /// This is for heads that do not require state. Those that do should use + /// `.head_with_state()` instead. + #[cfg(target_arch = "wasm32")] + pub fn head(self, _val: impl Fn() + 'static) -> Self { + self + } + /// Sets the function to set headers. This will override Perseus' inbuilt + /// header defaults. This should only be used when your header-setting + /// does not need state. + #[cfg(not(target_arch = "wasm32"))] + pub fn set_headers<V: Into<GeneratorResult<HeaderMap>>>( + mut self, + val: impl Fn(Scope) -> V + Send + Sync + 'static, + ) -> Self { + let template_name = self.get_path(); + self.set_headers = Some(Box::new(move |cx, _template_state| { + let template_name = template_name.clone(); + val(cx) + .into() + .into_server_result("set_headers", template_name) + })); + self + } + /// Sets the function to set headers. This will override Perseus' inbuilt + /// header defaults. This should only be used when your header-setting + /// does not need state. + #[cfg(target_arch = "wasm32")] + pub fn set_headers(self, _val: impl Fn() + 'static) -> Self { + self + } + + /// Enables the *build paths* strategy with the given function. + #[cfg(not(target_arch = "wasm32"))] + pub fn build_paths_fn<V: Into<GeneratorResult<BuildPaths>>>( + mut self, + val: impl GetBuildPathsUserFnType<V> + Clone + Send + Sync + 'static, + ) -> Self { + let template_name = self.get_path(); + self.get_build_paths = Some(Box::new(move || { + let val = val.clone(); + let template_name = template_name.clone(); + async move { + val.call() + .await + .into() + .into_server_result("build_paths", template_name) + } + })); + self + } + /// Enables the *build paths* strategy with the given function. + #[cfg(target_arch = "wasm32")] + pub fn build_paths_fn(self, _val: impl Fn() + 'static) -> Self { + self + } + + /// Enables the *incremental generation* strategy. + #[cfg(not(target_arch = "wasm32"))] + pub fn incremental_generation(mut self) -> Self { + self.incremental_generation = true; + self + } + /// Enables the *incremental generation* strategy. + #[cfg(target_arch = "wasm32")] + pub fn incremental_generation(self) -> Self { + self + } + + /// Enables the *build state* strategy with the given function. + #[cfg(not(target_arch = "wasm32"))] + pub fn build_state_fn<S, B, V>( + mut self, + val: impl GetBuildStateUserFnType<S, B, V> + Clone + Send + Sync + 'static, + ) -> Self + where + S: Serialize + DeserializeOwned + MakeRx, + B: Serialize + DeserializeOwned + Send + Sync + 'static, + V: Into<BlamedGeneratorResult<S>>, + { + let template_name = self.get_path(); + self.get_build_state = Some(Box::new( + move |info: StateGeneratorInfo<UnknownStateType>| { + let val = val.clone(); + let template_name = template_name.clone(); + async move { + let user_info = info.change_type::<B>(); + let user_state = val + .call(user_info) + .await + .into() + .into_server_result("build_state", template_name)?; + let template_state: TemplateState = user_state.into(); + Ok(template_state) + } + }, + )); + self + } + /// Enables the *build state* strategy with the given function. + #[cfg(target_arch = "wasm32")] + pub fn build_state_fn(self, _val: impl Fn() + 'static) -> Self { + self + } + + /// Enables the *request state* strategy with the given function. + #[cfg(not(target_arch = "wasm32"))] + pub fn request_state_fn<S, B, V>( + mut self, + val: impl GetRequestStateUserFnType<S, B, V> + Clone + Send + Sync + 'static, + ) -> Self + where + S: Serialize + DeserializeOwned + MakeRx, + B: Serialize + DeserializeOwned + Send + Sync + 'static, + V: Into<BlamedGeneratorResult<S>>, + { + let template_name = self.get_path(); + self.get_request_state = Some(Box::new( + move |info: StateGeneratorInfo<UnknownStateType>, req| { + let val = val.clone(); + let template_name = template_name.clone(); + async move { + let user_info = info.change_type::<B>(); + let user_state = val + .call(user_info, req) + .await + .into() + .into_server_result("request_state", template_name)?; + let template_state: TemplateState = user_state.into(); + Ok(template_state) + } + }, + )); + self + } + /// Enables the *request state* strategy with the given function. + #[cfg(target_arch = "wasm32")] + pub fn request_state_fn(self, _val: impl Fn() + 'static) -> Self { + self + } + + /// Enables the *revalidation* strategy (logic variant) with the given + /// function. + #[cfg(not(target_arch = "wasm32"))] + pub fn should_revalidate_fn<B, V>( + mut self, + val: impl ShouldRevalidateUserFnType<B, V> + Clone + Send + Sync + 'static, + ) -> Self + where + B: Serialize + DeserializeOwned + Send + Sync + 'static, + V: Into<BlamedGeneratorResult<bool>>, + { + let template_name = self.get_path(); + self.should_revalidate = Some(Box::new( + move |info: StateGeneratorInfo<UnknownStateType>, req| { + let val = val.clone(); + let template_name = template_name.clone(); + async move { + let user_info = info.change_type::<B>(); + val.call(user_info, req) + .await + .into() + .into_server_result("should_revalidate", template_name) + } + }, + )); + self + } + /// Enables the *revalidation* strategy (logic variant) with the given + /// function. + #[cfg(target_arch = "wasm32")] + pub fn should_revalidate_fn(self, _val: impl Fn() + 'static) -> Self { + self + } + + /// Enables the *revalidation* strategy (time variant). This takes a time + /// string of a form like `1w` for one week. + /// + /// - s: second, + /// - m: minute, + /// - h: hour, + /// - d: day, + /// - w: week, + /// - M: month (30 days used here, 12M ≠ 1y!), + /// - y: year (365 days always, leap years ignored, if you want them add + /// them as days) + #[cfg(not(target_arch = "wasm32"))] + pub fn revalidate_after<I: PerseusDuration>(mut self, val: I) -> Self { + let computed_duration = match val.into_computed() { + Ok(val) => val, + // This is fine, because this will be checked when we try to build the app (i.e. it'll + // show up before runtime) + Err(_) => panic!("invalid revalidation interval"), + }; + self.revalidate_after = Some(computed_duration); + self + } + /// Enables the *revalidation* strategy (time variant). This takes a time + /// string of a form like `1w` for one week. + /// + /// - s: second, + /// - m: minute, + /// - h: hour, + /// - d: day, + /// - w: week, + /// - M: month (30 days used here, 12M ≠ 1y!), + /// - y: year (365 days always, leap years ignored, if you want them add + /// them as days) + #[cfg(target_arch = "wasm32")] + pub fn revalidate_after<I: PerseusDuration>(self, _val: I) -> Self { + self + } + + /// Enables state amalgamation with the given function. State amalgamation + /// allows you to have one template generate state at both build time + /// and request time. The function you provide here is responsible for + /// rationalizing the two into one single state to be sent to the client, + /// and this will be run just after the request state function + /// completes. See [`States`] for further details. + #[cfg(not(target_arch = "wasm32"))] + pub fn amalgamate_states_fn<S, B, V>( + mut self, + val: impl AmalgamateStatesUserFnType<S, B, V> + Clone + Send + Sync + 'static, + ) -> Self + where + S: Serialize + DeserializeOwned + MakeRx + Send + Sync + 'static, + B: Serialize + DeserializeOwned + Send + Sync + 'static, + V: Into<BlamedGeneratorResult<S>>, + { + let template_name = self.get_path(); + self.amalgamate_states = Some(Box::new( + move |info: StateGeneratorInfo<UnknownStateType>, + build_state: TemplateState, + request_state: TemplateState| { + let val = val.clone(); + let template_name = template_name.clone(); + async move { + // Amalgamation logic will only be called if both states are indeed defined + let typed_build_state = build_state.change_type::<S>(); + let user_build_state = match typed_build_state.into_concrete() { + Ok(state) => state, + Err(err) => panic!( + "unrecoverable error in state amalgamation parameter derivation: {:#?}", + err + ), + }; + let typed_request_state = request_state.change_type::<S>(); + let user_request_state = match typed_request_state.into_concrete() { + Ok(state) => state, + Err(err) => panic!( + "unrecoverable error in state amalgamation parameter derivation: {:#?}", + err + ), + }; + let user_info = info.change_type::<B>(); + let user_state = val + .call(user_info, user_build_state, user_request_state) + .await + .into() + .into_server_result("amalgamate_states", template_name)?; + let template_state: TemplateState = user_state.into(); + Ok(template_state) + } + }, + )); + self + } + /// Enables state amalgamation with the given function. State amalgamation + /// allows you to have one template generate state at both build time + /// and request time. The function you provide here is responsible for + /// rationalizing the two into one single state to be sent to the client, + /// and this will be run just after the request state function + /// completes. See [`States`] for further details. + #[cfg(target_arch = "wasm32")] + pub fn amalgamate_states_fn(self, _val: impl Fn() + 'static) -> Self { + self + } + /// Allow the building of this page's templates to be rescheduled from + /// build-tim to request-time. + /// + /// A page whose state isn't generated at request-tim and isn't revalidated + /// can be rendered at build-time, unless it depends on capsules that + /// don't have those properties. If a page that could be rendered at + /// build-time were to render with a widget that revalidates later, that + /// prerender would be invalidated later, leading to render errors. If + /// that situation arises, and this hasn't been set, building will + /// return an error. + /// + /// If you receive one of those errors, it's almost always absolutely fine + /// to enable this, as the performance hit will usually be negligible. + /// If you notice a substantial difference though, you may wish to + /// reconsider. + pub fn allow_rescheduling(mut self) -> Self { + self.can_be_rescheduled = true; + self + } +} diff --git a/packages/perseus/src/template/core/state_setters.rs b/packages/perseus/src/template/core/state_setters.rs new file mode 100644 index 0000000000..c1b0bb72dd --- /dev/null +++ b/packages/perseus/src/template/core/state_setters.rs @@ -0,0 +1,215 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::super::fn_types::*; +use super::TemplateInner; +#[cfg(not(target_arch = "wasm32"))] +use crate::errors::*; +use crate::{ + reactor::Reactor, + state::{AnyFreeze, MakeRx, MakeUnrx, UnreactiveState}, +}; +#[cfg(not(target_arch = "wasm32"))] +use http::HeaderMap; +use serde::{de::DeserializeOwned, Serialize}; +use sycamore::prelude::BoundedScope; +use sycamore::prelude::{create_child_scope, create_ref}; +#[cfg(not(target_arch = "wasm32"))] +use sycamore::web::SsrNode; +use sycamore::{prelude::Scope, view::View, web::Html}; + +impl<G: Html> TemplateInner<G> { + // The view functions below are shadowed for widgets, and therefore these + // definitions only apply to templates, not capsules! + + /// Sets the template rendering function to use, if the template takes + /// state. Templates that do not take state should use `.template()` + /// instead. + /// + /// The closure wrapping this performs will automatically handle suspense + /// state. + // Generics are swapped here for nicer manual specification + pub fn view_with_state<I, F>(mut self, val: F) -> Self + where + // The state is made reactive on the child + F: for<'app, 'child> Fn(BoundedScope<'app, 'child>, &'child I) -> View<G> + + Send + + Sync + + 'static, + I: MakeUnrx + AnyFreeze + Clone, + I::Unrx: MakeRx<Rx = I> + Serialize + DeserializeOwned + Send + Sync + Clone + 'static, + { + self.view = Box::new( + #[allow(unused_variables)] + move |app_cx, preload_info, template_state, path| { + let reactor = Reactor::<G>::from_cx(app_cx); + // This will handle frozen/active state prioritization, etc. + let intermediate_state = + reactor.get_page_state::<I::Unrx>(&path, template_state)?; + // Run the user's code in a child scope so any effects they start are killed + // when the page ends (otherwise we basically get a series of + // continuous pseudo-memory leaks, which can also cause accumulations of + // listeners on things like the router state) + let mut view = View::empty(); + let disposer = ::sycamore::reactive::create_child_scope(app_cx, |child_cx| { + // Compute suspended states + #[cfg(target_arch = "wasm32")] + intermediate_state.compute_suspense(child_cx); + + view = val(child_cx, create_ref(child_cx, intermediate_state)); + }); + Ok((view, disposer)) + }, + ); + self + } + /// Sets the template rendering function to use, if the template takes + /// unreactive state. + pub fn view_with_unreactive_state<F, S>(mut self, val: F) -> Self + where + F: Fn(Scope, S) -> View<G> + Send + Sync + 'static, + S: MakeRx + Serialize + DeserializeOwned + UnreactiveState + 'static, + <S as MakeRx>::Rx: AnyFreeze + Clone + MakeUnrx<Unrx = S>, + { + self.view = Box::new( + #[allow(unused_variables)] + move |app_cx, preload_info, template_state, path| { + let reactor = Reactor::<G>::from_cx(app_cx); + // This will handle frozen/active state prioritization, etc. + let intermediate_state = reactor.get_page_state::<S>(&path, template_state)?; + let mut view = View::empty(); + let disposer = create_child_scope(app_cx, |child_cx| { + // We go back from the unreactive state type wrapper to the base type (since + // it's unreactive) + view = val(child_cx, intermediate_state.make_unrx()); + }); + Ok((view, disposer)) + }, + ); + self + } + + /// Sets the template rendering function to use for templates that take no + /// state. Templates that do take state should use + /// `.template_with_state()` instead. + pub fn view<F>(mut self, val: F) -> Self + where + F: Fn(Scope) -> View<G> + Send + Sync + 'static, + { + self.view = Box::new(move |app_cx, _preload_info, _template_state, path| { + let reactor = Reactor::<G>::from_cx(app_cx); + // Declare that this page/widget will never take any state to enable full + // caching + reactor.register_no_state(&path, false); + + // Nicely, if this is a widget, this means there need be no network requests + // at all! + let mut view = View::empty(); + let disposer = ::sycamore::reactive::create_child_scope(app_cx, |child_cx| { + view = val(child_cx); + }); + Ok((view, disposer)) + }); + self + } + + /// Sets the document `<head>` rendering function to use. The [`View`] + /// produced by this will only be rendered on the engine-side, and will + /// *not* be reactive (since it only contains metadata). + /// + /// This is for heads that do require state. Those that do not should use + /// `.head()` instead. + #[cfg(not(target_arch = "wasm32"))] + pub fn head_with_state<S, V>( + mut self, + val: impl Fn(Scope, S) -> V + Send + Sync + 'static, + ) -> Self + where + S: Serialize + DeserializeOwned + MakeRx + 'static, + V: Into<GeneratorResult<View<SsrNode>>>, + { + let template_name = self.get_path(); + self.head = Some(Box::new(move |cx, template_state| { + // Make sure now that there is actually state + if template_state.is_empty() { + return Err(ClientError::InvariantError(ClientInvariantError::NoState).into()); + } + // Declare a type on the untyped state (this doesn't perform any conversions, + // but the type we declare may be invalid) + let typed_state = template_state.change_type::<S>(); + + let state = + match typed_state.into_concrete() { + Ok(state) => state, + Err(err) => { + return Err(ClientError::InvariantError( + ClientInvariantError::InvalidState { source: err }, + ) + .into()) + } + }; + + let template_name = template_name.clone(); + val(cx, state) + .into() + .into_server_result("head", template_name) + })); + self + } + /// Sets the document `<head>` rendering function to use. The [`View`] + /// produced by this will only be rendered on the engine-side, and will + /// *not* be reactive (since it only contains metadata). + /// + /// This is for heads that do require state. Those that do not should use + /// `.head()` instead. + #[cfg(target_arch = "wasm32")] + pub fn head_with_state(self, _val: impl Fn() + 'static) -> Self { + self + } + + /// Sets the function to set headers. This will override Perseus' inbuilt + /// header defaults. This should only be used when your header-setting + /// requires knowing the state. + #[cfg(not(target_arch = "wasm32"))] + pub fn set_headers_with_state<S, V>( + mut self, + val: impl Fn(Scope, S) -> V + Send + Sync + 'static, + ) -> Self + where + S: Serialize + DeserializeOwned + MakeRx + 'static, + V: Into<GeneratorResult<HeaderMap>>, + { + let template_name = self.get_path(); + self.set_headers = Some(Box::new(move |cx, template_state| { + // Make sure now that there is actually state + if template_state.is_empty() { + return Err(ClientError::InvariantError(ClientInvariantError::NoState).into()); + } + // Declare a type on the untyped state (this doesn't perform any conversions, + // but the type we declare may be invalid) + let typed_state = template_state.change_type::<S>(); + + let state = + match typed_state.into_concrete() { + Ok(state) => state, + Err(err) => { + return Err(ClientError::InvariantError( + ClientInvariantError::InvalidState { source: err }, + ) + .into()) + } + }; + + let template_name = template_name.clone(); + val(cx, state) + .into() + .into_server_result("set_headers", template_name) + })); + self + } + /// Sets the function to set headers. This will override Perseus' inbuilt + /// header defaults. This should only be used when your header-setting + /// requires knowing the state. + #[cfg(target_arch = "wasm32")] + pub fn set_headers_with_state(self, _val: impl Fn() + 'static) -> Self { + self + } +} diff --git a/packages/perseus/src/template/core/utils.rs b/packages/perseus/src/template/core/utils.rs new file mode 100644 index 0000000000..e4436ef1d0 --- /dev/null +++ b/packages/perseus/src/template/core/utils.rs @@ -0,0 +1,11 @@ +/// An internal subset of `RouteInfo` that stores the details needed for +/// preloading. +/// +/// This exists on the engine-side for type convenience, but only has fields +/// on the browser-side. +pub(crate) struct PreloadInfo { + #[cfg(target_arch = "wasm32")] + pub(crate) locale: String, + #[cfg(target_arch = "wasm32")] + pub(crate) was_incremental_match: bool, +} diff --git a/packages/perseus/src/template/fn_types.rs b/packages/perseus/src/template/fn_types.rs new file mode 100644 index 0000000000..b4c5a7d982 --- /dev/null +++ b/packages/perseus/src/template/fn_types.rs @@ -0,0 +1,290 @@ +use crate::{ + errors::*, + make_async_trait, + state::{BuildPaths, MakeRx, StateGeneratorInfo, TemplateState, UnknownStateType}, + utils::AsyncFnReturn, + Request, +}; +use futures::Future; +use http::HeaderMap; +use serde::{de::DeserializeOwned, Serialize}; +use sycamore::{prelude::Scope, view::View, web::SsrNode}; + +/// A custom `enum` representation of a `Result`-style type whose error is a +/// `Box` that can accept any thread-safe error type. This is used internally as +/// a conversion type so that your state generation functions (e.g. +/// `get_build_state`) can return *either* some type `T`, or a `Result<T, E>`, +/// where `E` is of your choosing. This type appears as +/// `Into<GeneratorResult<T>>` in several function signatures, and that +/// `From`/`Into` relation is automatically implemented for all the types you'll +/// need in your state generation functions. +/// +/// You can think of this as another way of implementing a `MaybeFallible` +/// trait, which would be more elegant, but doesn't work yet due to the overlap +/// between `T` and `Result<T, E>` (which could itself be interpreted as `T`). +/// Until certain nightly features of Rust as stabilized, this is not possible +/// without copious type parameter specification. +/// +/// You should never need to use this type yourself, consider it an internal +/// conversion type. +#[derive(Debug)] +pub enum GeneratorResult<T> { + /// Equivalent to `Result::Ok`. + Ok(T), + /// Equivalent to `Result::Err`. + Err(Box<dyn std::error::Error + Send + Sync>), +} +impl<T> GeneratorResult<T> { + /// Converts this `enum` into a `Result` amenable to typical usage within + /// Perseus' engine-side. Since this type has no blame, any errors will + /// be implicitly blamed on the server with no special status + /// code (leading to the return of a *500 Internal Server Error* if the + /// error propagates at request-time). + pub(crate) fn into_server_result( + self, + fn_name: &str, + template_name: String, + ) -> Result<T, ServerError> { + match self { + Self::Ok(val) => Ok(val), + Self::Err(err) => Err(ServerError::RenderFnFailed { + fn_name: fn_name.to_string(), + template_name, + blame: ErrorBlame::Server(None), + source: err, + }), + } + } +} +/// The same as [`GeneratorResult`], except this uses a [`GenericBlamedError`] +/// as its error type, which is essentially a `Box`ed generic error with an +/// attached [`ErrorBlame`] denoting who is responsible for the error: the +/// client or the server. You'll see this as a convertion type in the signatures +/// of functions that might be run at reuqest-time (e.g. `get_request_state` +/// might have an error caused by a missing file, which would be the server's +/// fault, or a malformed cookie, which would be the client's fault). +/// +/// For a function that returns `Into<BlamedGeneratorResult<T>>`, you can return +/// either `T` directly, or `Result<T, BlamedError<E>>`: see [`BlamedError`] for +/// further information. (Note that the `?` operator can automatically turn `E` +/// into `BlamedError<E>`, setting the server as the one to blame.) +#[derive(Debug)] +pub enum BlamedGeneratorResult<T> { + /// Equivalent to `Result::Ok`. + Ok(T), + /// Equivalent to `Result::Err`. + Err(GenericBlamedError), +} +impl<T> BlamedGeneratorResult<T> { + /// Converts this `enum` into a `Result` amenable to typical usage within + /// Perseus' engine-side. This will use the underlying error blame. + pub(crate) fn into_server_result( + self, + fn_name: &str, + template_name: String, + ) -> Result<T, ServerError> { + match self { + Self::Ok(val) => Ok(val), + Self::Err(err) => Err(ServerError::RenderFnFailed { + fn_name: fn_name.to_string(), + template_name, + blame: err.blame, + source: err.error, + }), + } + } +} + +// We manually implement everything we need here (and only what we need). A +// neater approach would be a `MaybeFallible` trait, but that needs an +// implementation for both `T` and `Result<T, E>`, which overlap. With +// rust-lang/rust#31844, that would be possible, but that seems to be quite a +// while away. + +// Build paths +impl From<BuildPaths> for GeneratorResult<BuildPaths> { + fn from(val: BuildPaths) -> Self { + Self::Ok(val) + } +} +impl<E: std::error::Error + Send + Sync + 'static> From<Result<BuildPaths, E>> + for GeneratorResult<BuildPaths> +{ + fn from(val: Result<BuildPaths, E>) -> Self { + match val { + Ok(val) => Self::Ok(val), + Err(err) => Self::Err(err.into()), + } + } +} +// Global build state (*not* blamed) +impl<S: Serialize + DeserializeOwned + MakeRx> From<S> for GeneratorResult<S> { + fn from(val: S) -> Self { + Self::Ok(val) + } +} +impl<S: Serialize + DeserializeOwned + MakeRx, E: std::error::Error + Send + Sync + 'static> + From<Result<S, E>> for GeneratorResult<S> +{ + fn from(val: Result<S, E>) -> Self { + match val { + Ok(val) => Self::Ok(val), + Err(err) => Self::Err(err.into()), + } + } +} +// Head +impl From<View<SsrNode>> for GeneratorResult<View<SsrNode>> { + fn from(val: View<SsrNode>) -> Self { + Self::Ok(val) + } +} +impl<E: std::error::Error + Send + Sync + 'static> From<Result<View<SsrNode>, E>> + for GeneratorResult<View<SsrNode>> +{ + fn from(val: Result<View<SsrNode>, E>) -> Self { + match val { + Ok(val) => Self::Ok(val), + Err(err) => Self::Err(err.into()), + } + } +} +// Headers +impl From<HeaderMap> for GeneratorResult<HeaderMap> { + fn from(val: HeaderMap) -> Self { + Self::Ok(val) + } +} +impl<E: std::error::Error + Send + Sync + 'static> From<Result<HeaderMap, E>> + for GeneratorResult<HeaderMap> +{ + fn from(val: Result<HeaderMap, E>) -> Self { + match val { + Ok(val) => Self::Ok(val), + Err(err) => Self::Err(err.into()), + } + } +} +// Build/request state and state amalgamation (blamed; they all have the same +// return types) +impl<S: Serialize + DeserializeOwned + MakeRx> From<S> for BlamedGeneratorResult<S> { + fn from(val: S) -> Self { + Self::Ok(val) + } +} +impl<S: Serialize + DeserializeOwned + MakeRx, E: std::error::Error + Send + Sync + 'static> + From<Result<S, BlamedError<E>>> for BlamedGeneratorResult<S> +{ + fn from(val: Result<S, BlamedError<E>>) -> Self { + match val { + Ok(val) => Self::Ok(val), + Err(err) => Self::Err(err.into_boxed()), + } + } +} +// Should revalidate (blamed) +impl From<bool> for BlamedGeneratorResult<bool> { + fn from(val: bool) -> Self { + Self::Ok(val) + } +} +impl<E: std::error::Error + Send + Sync + 'static> From<Result<bool, BlamedError<E>>> + for BlamedGeneratorResult<bool> +{ + fn from(val: Result<bool, BlamedError<E>>) -> Self { + match val { + Ok(val) => Self::Ok(val), + Err(err) => Self::Err(err.into_boxed()), + } + } +} + +// A series of asynchronous closure traits that prevent the user from having to +// pin their functions +make_async_trait!( + pub(crate) GetBuildPathsFnType, + Result<BuildPaths, ServerError> +); +// The build state strategy needs an error cause if it's invoked from +// incremental +make_async_trait!( + pub(super) GetBuildStateFnType, + Result<TemplateState, ServerError>, + info: StateGeneratorInfo<UnknownStateType> +); +make_async_trait!( + pub(super) GetRequestStateFnType, + Result<TemplateState, ServerError>, + info: StateGeneratorInfo<UnknownStateType>, + req: Request +); +make_async_trait!( + pub(super) ShouldRevalidateFnType, + Result<bool, ServerError>, + info: StateGeneratorInfo<UnknownStateType>, + req: Request +); +make_async_trait!( + pub(super) AmalgamateStatesFnType, + Result<TemplateState, ServerError>, + info: StateGeneratorInfo<UnknownStateType>, + build_state: TemplateState, + request_state: TemplateState +); + +// These traits are for the functions users provide to us! They are NOT stored +// internally! As `R` denotes reference reactive state elsewhere, `V` is used +// here for return types. Also, for some reason macros don't do `>>`, so we need +// random spaces. +make_async_trait!( + pub GetBuildPathsUserFnType< V: Into< GeneratorResult<BuildPaths> > >, + V +); +make_async_trait!( + pub GetBuildStateUserFnType< S: Serialize + DeserializeOwned + MakeRx, B: Serialize + DeserializeOwned + Send + Sync, V: Into< BlamedGeneratorResult<S> > >, + V, + info: StateGeneratorInfo<B> +); +make_async_trait!( + pub GetRequestStateUserFnType< S: Serialize + DeserializeOwned + MakeRx, B: Serialize + DeserializeOwned + Send + Sync, V: Into< BlamedGeneratorResult<S> > >, + V, + info: StateGeneratorInfo<B>, + req: Request +); +make_async_trait!( + pub ShouldRevalidateUserFnType< B: Serialize + DeserializeOwned + Send + Sync, V: Into< BlamedGeneratorResult<bool> > >, + V, + info: StateGeneratorInfo<B>, + req: Request +); +make_async_trait!( + pub AmalgamateStatesUserFnType< S: Serialize + DeserializeOwned + MakeRx, B: Serialize + DeserializeOwned + Send + Sync, V: Into< BlamedGeneratorResult<S> > >, + V, + info: StateGeneratorInfo<B>, + build_state: S, + request_state: S +); + +// A series of closure types that should not be typed out more than once + +// Note: the head and header functions have render errors constructed inside +// their closures! +/// 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(crate) type HeadFn = + Box<dyn Fn(Scope, TemplateState) -> Result<View<SsrNode>, ServerError> + Send + Sync>; +/// The type of functions that modify HTTP response headers. +pub(crate) type SetHeadersFn = + Box<dyn Fn(Scope, TemplateState) -> Result<HeaderMap, ServerError> + Send + Sync>; +/// The type of functions that get build paths. +pub(crate) type GetBuildPathsFn = Box<dyn GetBuildPathsFnType + Send + Sync>; +/// The type of functions that get build state. +pub(crate) type GetBuildStateFn = Box<dyn GetBuildStateFnType + Send + Sync>; +/// The type of functions that get request state. +pub(crate) type GetRequestStateFn = Box<dyn GetRequestStateFnType + Send + Sync>; +/// The type of functions that check if a template should revalidate. +pub(crate) type ShouldRevalidateFn = Box<dyn ShouldRevalidateFnType + Send + Sync>; +/// The type of functions that amalgamate build and request states. +pub(crate) type AmalgamateStatesFn = Box<dyn AmalgamateStatesFnType + Send + Sync>; diff --git a/packages/perseus/src/template/mod.rs b/packages/perseus/src/template/mod.rs index 275f5ccb24..142300579b 100644 --- a/packages/perseus/src/template/mod.rs +++ b/packages/perseus/src/template/mod.rs @@ -1,16 +1,40 @@ mod core; // So called because this contains what is essentially the core exposed logic of Perseus #[cfg(not(target_arch = "wasm32"))] mod default_headers; -mod render_ctx; +// mod render_ctx; +mod capsule; +#[cfg(not(target_arch = "wasm32"))] +mod fn_types; #[cfg(not(target_arch = "wasm32"))] mod states; -mod templates_map; +mod widget_component; -pub use self::core::*; /* There are a lot of render function traits in here, there's no - * point in spelling them all out */ +pub use self::core::*; +#[cfg(not(target_arch = "wasm32"))] +pub use fn_types::*; /* There are a lot of render function traits in here, there's no + * point in spelling them all out */ #[cfg(not(target_arch = "wasm32"))] pub(crate) use default_headers::default_headers; -pub use render_ctx::RenderCtx; +// pub use render_ctx::RenderCtx; +// pub(crate) use render_ctx::{RenderMode, RenderStatus}; +pub use capsule::Capsule; #[cfg(not(target_arch = "wasm32"))] pub(crate) use states::States; -pub use templates_map::{ArcTemplateMap, TemplateMap}; + +use crate::{errors::ClientError, path::PathMaybeWithLocale, state::TemplateState}; +use sycamore::{ + prelude::{Scope, ScopeDisposer}, + view::View, +}; +// Everything else in `fn_types.rs` is engine-only +/// The type of functions that are given a state and render a page. +pub(crate) type TemplateFn<G> = Box< + dyn for<'a> Fn( + Scope<'a>, + PreloadInfo, + TemplateState, + PathMaybeWithLocale, + ) -> Result<(View<G>, ScopeDisposer<'a>), ClientError> + + Send + + Sync, +>; diff --git a/packages/perseus/src/template/render_ctx.rs b/packages/perseus/src/template/render_ctx.rs index a483f56f8d..a734221b34 100644 --- a/packages/perseus/src/template/render_ctx.rs +++ b/packages/perseus/src/template/render_ctx.rs @@ -1,16 +1,23 @@ #[cfg(target_arch = "wasm32")] -use super::TemplateNodeType; -use super::{TemplateState, TemplateStateWithType}; -use crate::errors::*; +use super::BrowserNodeType; +use super::{ArcTemplateMap, TemplateState, TemplateStateWithType}; +use crate::{PathMaybeWithLocale, PathWithoutLocale, errors::*}; use crate::router::{RouterLoadState, RouterState}; use crate::state::{ AnyFreeze, Freeze, FrozenApp, GlobalState, GlobalStateType, MakeRx, MakeRxRef, MakeUnrx, PageStateStore, RxRef, ThawPrefs, }; +use crate::stores::ImmutableStore; use std::cell::RefCell; use std::rc::Rc; +use serde_json::Value; use sycamore::prelude::{provide_context, use_context, Scope}; +use sycamore::web::{Html, SsrNode}; use sycamore_router::navigate; +#[cfg(not(target_arch = "wasm32"))] +use std::collections::HashMap; + + /// A representation of the render context of the app, constructed from /// references to a series of `struct`s that mirror context values. This is @@ -61,7 +68,7 @@ pub struct RenderCtx { /// should always return a build-time error rather than produce a page /// with an error in it. #[cfg(target_arch = "wasm32")] - pub error_pages: Rc<crate::error_pages::ErrorPages<TemplateNodeType>>, + pub error_pages: Rc<crate::error_pages::ErrorPages<BrowserNodeType>>, // --- PRIVATE FIELDS --- // Any users accessing these are *extremely* likely to shoot themselves in the foot! /// Whether or not this page is the very first to have been rendered since @@ -75,13 +82,20 @@ pub struct RenderCtx { pub(crate) locales: crate::i18n::Locales, /// The map of all templates in the app, for use in routing. #[cfg(target_arch = "wasm32")] - pub(crate) templates: crate::template::TemplateMap<TemplateNodeType>, + pub(crate) templates: crate::template::TemplateMap<BrowserNodeType>, /// The render configuration, for use in routing. #[cfg(target_arch = "wasm32")] pub(crate) render_cfg: Rc<std::collections::HashMap<String, String>>, /// The client-side translations manager. #[cfg(target_arch = "wasm32")] pub(crate) translations_manager: crate::i18n::ClientTranslationsManager, + /// The mode we're currently rendering in. This will be used primarily in widget rendering. + /// + /// While a user could *hypothetically* render in a manner that's dependent on this, that is + /// never going to be a good idea, since the internals of this could change in any release. Hence, + /// we keep it private to the crate. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) render_mode: RenderMode<SsrNode>, } impl Freeze for RenderCtx { /// 'Freezes' the relevant parts of the render configuration to a serialized @@ -91,14 +105,13 @@ impl Freeze for RenderCtx { let frozen_app = FrozenApp { global_state: self.global_state.0.borrow().freeze(), route: match &*self.router.get_load_state_rc().get_untracked() { - RouterLoadState::Loaded { path, .. } => path, - RouterLoadState::Loading { path, .. } => path, - RouterLoadState::ErrorLoaded { path } => path, + RouterLoadState::Loaded { path, .. } => Some(path.clone()), + RouterLoadState::Loading { path, .. } => Some(path.clone()), // If we encounter this during re-hydration, we won't try to set the URL in the // browser - RouterLoadState::Server => "SERVER", - } - .to_string(), + RouterLoadState::ErrorLoaded { .. } => None, + RouterLoadState::Server => None, + }, page_state_store: self.page_state_store.freeze_to_hash_map(), }; serde_json::to_string(&frozen_app).unwrap() @@ -107,8 +120,8 @@ impl Freeze for RenderCtx { #[cfg(not(target_arch = "wasm32"))] // To prevent foot-shooting impl RenderCtx { /// Initializes a new `RenderCtx` on the server-side with the given global - /// state. - pub(crate) fn server(global_state: TemplateState) -> Self { + /// state and set of widget states. + pub(crate) fn server(global_state: TemplateState, mode: RenderMode<SsrNode>) -> Self { Self { router: RouterState::default(), page_state_store: PageStateStore::new(0), /* There will be no need for the PSS on the @@ -119,6 +132,7 @@ impl RenderCtx { GlobalState::new(GlobalStateType::None) }, frozen_app: Rc::new(RefCell::new(None)), + render_mode: mode, } } } @@ -135,9 +149,9 @@ impl RenderCtx { pub(crate) fn new( pss_max_size: usize, locales: crate::i18n::Locales, - templates: crate::template::TemplateMap<TemplateNodeType>, + templates: crate::template::TemplateMap<BrowserNodeType>, render_cfg: Rc<std::collections::HashMap<String, String>>, - error_pages: Rc<crate::error_pages::ErrorPages<TemplateNodeType>>, + error_pages: Rc<crate::error_pages::ErrorPages<BrowserNodeType>>, ) -> Self { let translations_manager = crate::i18n::ClientTranslationsManager::new(&locales); Self { @@ -184,9 +198,8 @@ impl RenderCtx { // Conveniently, we can use the lifetime mechanics of knowing that the render context // is registered on the given scope to ensure that the future works out #[cfg(target_arch = "wasm32")] - pub fn preload<'a, 'b: 'a>(&'b self, cx: Scope<'a>, url: &str) { + pub fn preload<'a, 'b: 'a>(&'b self, cx: Scope<'a>, url: &PathMaybeWithLocale) { use fmterr::fmt_err; - let url = url.to_string(); crate::spawn_local_scoped(cx, async move { if let Err(err) = self.try_preload(&url).await { @@ -212,9 +225,8 @@ impl RenderCtx { // Conveniently, we can use the lifetime mechanics of knowing that the render context // is registered on the given scope to ensure that the future works out #[cfg(target_arch = "wasm32")] - pub fn route_preload<'a, 'b: 'a>(&'b self, cx: Scope<'a>, url: &str) { + pub fn route_preload<'a, 'b: 'a>(&'b self, cx: Scope<'a>, url: &PathMaybeWithLocale) { use fmterr::fmt_err; - let url = url.to_string(); crate::spawn_local_scoped(cx, async move { if let Err(err) = self.try_route_preload(&url).await { @@ -226,20 +238,20 @@ impl RenderCtx { /// error. If the path you're preloading is not hardcoded, you should /// use this. #[cfg(target_arch = "wasm32")] - pub async fn try_preload(&self, url: &str) -> Result<(), ClientError> { + pub async fn try_preload(&self, url: &PathMaybeWithLocale) -> Result<(), ClientError> { self._preload(url, false).await } /// A version of `.route_preload()` that returns a future that can resolve /// to an error. If the path you're preloading is not hardcoded, you /// should use this. #[cfg(target_arch = "wasm32")] - pub async fn try_route_preload(&self, url: &str) -> Result<(), ClientError> { + pub async fn try_route_preload(&self, url: &PathMaybeWithLocale) -> Result<(), ClientError> { self._preload(url, true).await } /// Preloads the given URL from the server and caches it, preventing /// future network requests to fetch that page. #[cfg(target_arch = "wasm32")] - async fn _preload(&self, path: &str, is_route_preload: bool) -> Result<(), ClientError> { + async fn _preload(&self, path: &PathMaybeWithLocale, is_route_preload: bool) -> Result<(), ClientError> { use crate::router::{match_route, RouteVerdict}; let path_segments = path @@ -287,6 +299,9 @@ impl RenderCtx { /// However, if the frozen state for an individual page is invalid, it will /// be silently ignored in favor of either the active state or the /// server-provided state. + /// + /// If the app was last frozen while on an error page, this will not attempt + /// to change the current route. pub fn thaw(&self, new_frozen_app: &str, thaw_prefs: ThawPrefs) -> Result<(), ClientError> { let new_frozen_app: FrozenApp = serde_json::from_str(new_frozen_app) .map_err(|err| ClientError::ThawFailed { source: err })?; @@ -300,22 +315,25 @@ impl RenderCtx { // Check if we're on the same page now as we were at freeze-time let curr_route = match &*self.router.get_load_state_rc().get_untracked() { - RouterLoadState::Loaded { path, .. } => path.to_string(), - RouterLoadState::Loading { path, .. } => path.to_string(), - RouterLoadState::ErrorLoaded { path } => path.to_string(), + RouterLoadState::Loaded { path, .. } => path.clone(), + RouterLoadState::Loading { path, .. } => path.clone(), + // We're in an error state, so we have no choice but to go to the old route + RouterLoadState::ErrorLoaded { location } => todo!("thawing while in an error state is not yet implemented"), // The user is trying to thaw on the server, which is an absolutely horrific idea (we should be generating state, and loops could happen) RouterLoadState::Server => panic!("attempted to thaw frozen state on server-side (you can only do this in the browser)"), }; // We handle the possibility that the page tried to reload before it had been // made interactive here (we'll just reload wherever we are) - if curr_route == route || route == "SERVER" { - // We'll need to imperatively instruct the router to reload the current page - // (Sycamore can't do this yet) We know the last verdict will be - // available because the only way we can be here is if we have a page - self.router.reload(); + if let Some(route) = route { + // If we're on the same page, just reload, otherwise go to the frozen route + if curr_route == route { + self.router.reload(); + } else { + navigate(&route); + } } else { - // We aren't, navigate to the old route as usual - navigate(&route); + // The page froze before hydration, so we'll jsut reload + self.router.reload(); } Ok(()) @@ -329,7 +347,7 @@ impl RenderCtx { /// If this occurs, something has gone horribly wrong, and panics will /// almost certainly follow. (Basically, this should *never* happen. If /// you're not using the macros, you may need to be careful of this.) - fn get_frozen_page_state_and_register<R>(&self, url: &str) -> Option<<R::Unrx as MakeRx>::Rx> + fn get_frozen_page_state_and_register<R>(&self, url: &PathMaybeWithLocale, is_widget: bool) -> Option<<R::Unrx as MakeRx>::Rx> where R: Clone + AnyFreeze + MakeUnrx, // We need this so that the compiler understands that the reactive version of the @@ -342,7 +360,7 @@ impl RenderCtx { // active state if thaw_prefs.page.should_use_frozen_state(url) { // Get the serialized and unreactive frozen state from the store - match frozen_app.page_state_store.get(url) { + match frozen_app.page_state_store.get(&url) { Some(state_str) => { // Deserialize into the unreactive version let unrx = match serde_json::from_str::<R::Unrx>(state_str) { @@ -360,7 +378,7 @@ impl RenderCtx { let rx = unrx.make_rx(); // And we do want to add this to the page state store (if this returns // false, then this page was never supposed to receive state) - if !self.page_state_store.add_state(url, rx.clone()) { + if !self.page_state_store.add_state(url, rx.clone(), is_widget) { return None; } // Now we should remove this from the frozen state so we don't fall back to @@ -387,7 +405,7 @@ impl RenderCtx { } /// An internal getter for the active (already registered) state for the /// given page. - fn get_active_page_state<R>(&self, url: &str) -> Option<<R::Unrx as MakeRx>::Rx> + fn get_active_page_state<R>(&self, url: &PathMaybeWithLocale) -> Option<<R::Unrx as MakeRx>::Rx> where R: Clone + AnyFreeze + MakeUnrx, // We need this so that the compiler understands that the reactive version of the @@ -404,7 +422,7 @@ impl RenderCtx { /// /// This takes a single type parameter for the reactive state type, from /// which the unreactive state type can be derived. - pub fn get_active_or_frozen_page_state<R>(&self, url: &str) -> Option<<R::Unrx as MakeRx>::Rx> + pub fn get_active_or_frozen_page_state<R>(&self, url: &PathMaybeWithLocale, is_widget: bool) -> Option<<R::Unrx as MakeRx>::Rx> where R: Clone + AnyFreeze + MakeUnrx, // We need this so that the compiler understands that the reactive version of the @@ -418,7 +436,7 @@ impl RenderCtx { if thaw_prefs.page.should_use_frozen_state(url) { drop(frozen_app_full); // We'll fall back to active state if no frozen state is available - match self.get_frozen_page_state_and_register::<R>(url) { + match self.get_frozen_page_state_and_register::<R>(url, is_widget) { Some(state) => Some(state), None => self.get_active_page_state::<R>(url), } @@ -428,7 +446,7 @@ impl RenderCtx { // available match self.get_active_page_state::<R>(url) { Some(state) => Some(state), - None => self.get_frozen_page_state_and_register::<R>(url), + None => self.get_frozen_page_state_and_register::<R>(url, is_widget), } } } else { @@ -557,8 +575,9 @@ impl RenderCtx { /// not using the macros, you may need to be careful of this.) pub fn register_page_state_value<R>( &self, - url: &str, + url: &PathMaybeWithLocale, state: TemplateStateWithType<R::Unrx>, + is_widget: bool, ) -> Result<<R::Unrx as MakeRx>::Rx, ClientError> where R: Clone + AnyFreeze + MakeUnrx, @@ -573,7 +592,7 @@ impl RenderCtx { .map_err(|err| ClientError::StateInvalid { source: err })?; let rx = unrx.make_rx(); // Potential silent failure (see above) - let _ = self.page_state_store.add_state(url, rx.clone()); + let _ = self.page_state_store.add_state(url, rx.clone(), is_widget); Ok(rx) } @@ -603,8 +622,8 @@ impl RenderCtx { /// Registers a page as definitely taking no state, which allows it to be /// cached fully, preventing unnecessary network requests. Any future /// attempt to set state will lead to silent failures and/or panics. - pub fn register_page_no_state(&self, url: &str) { - self.page_state_store.set_state_never(url); + pub fn register_page_no_state(&self, url: &PathMaybeWithLocale, is_widget: bool) { + self.page_state_store.set_state_never(url, is_widget); } /// Gets the global state. diff --git a/packages/perseus/src/template/states.rs b/packages/perseus/src/template/states.rs index e90ab613e4..a22ee7790c 100644 --- a/packages/perseus/src/template/states.rs +++ b/packages/perseus/src/template/states.rs @@ -1,6 +1,5 @@ use crate::errors::*; - -use super::TemplateState; +use crate::state::TemplateState; /// Represents all the different states that can be generated for a single /// template, allowing amalgamation logic to be run with the knowledge @@ -13,20 +12,14 @@ pub(crate) struct States { pub request_state: TemplateState, } impl States { - /// Creates a new instance of the states, setting both to `None`. - pub fn new() -> Self { - Self { - build_state: TemplateState::empty(), - request_state: TemplateState::empty(), - } - } /// Checks if both request state and build state are defined. pub fn both_defined(&self) -> bool { !self.build_state.is_empty() && !self.request_state.is_empty() } /// Gets the only defined state if only one is defined. If no states are /// defined, this will just return `None`. If both are defined, - /// this will return an error. + /// this will return an error. (Under no other conditions may this + /// ever return an error.) pub fn get_defined(&self) -> Result<TemplateState, ServeError> { if self.both_defined() { return Err(ServeError::BothStatesDefined); diff --git a/packages/perseus/src/template/templates_map.rs b/packages/perseus/src/template/templates_map.rs deleted file mode 100644 index d55fabd56e..0000000000 --- a/packages/perseus/src/template/templates_map.rs +++ /dev/null @@ -1,57 +0,0 @@ -use super::Template; -use std::collections::HashMap; -use std::rc::Rc; -use std::sync::Arc; - -/// 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. -#[macro_export] -macro_rules! get_templates_map { - [ - $($template:expr),+ - ] => { - { - let mut map = ::std::collections::HashMap::new(); - $( - map.insert( - $template.get_path(), - ::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) - ); - )+ - - map - } - }; -} - -/// 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<G> = HashMap<String, Rc<Template<G>>>; -/// 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<G> = HashMap<String, Arc<Template<G>>>; diff --git a/packages/perseus/src/template/widget_component.rs b/packages/perseus/src/template/widget_component.rs new file mode 100644 index 0000000000..a008463d47 --- /dev/null +++ b/packages/perseus/src/template/widget_component.rs @@ -0,0 +1,423 @@ +use std::any::TypeId; + +use crate::path::PathWithoutLocale; +#[cfg(not(target_arch = "wasm32"))] +use sycamore::prelude::create_child_scope; +use sycamore::{prelude::Scope, view::View, web::Html}; + +use super::Capsule; + +impl<G: Html, P: Clone + 'static> Capsule<G, P> { + /// Creates a component for a single widget that this capsule can produce, + /// based on the given path. This is designed to be used inside the + /// Sycamore `view!` macro. + /// + /// Note that this will not behave like a normal Sycamore component, and it + /// is effectively a normal function (for now). + /// + /// The path provided to this should not include the name of the capsule + /// itself. For example, if the capsule path is `foo`, and you want the + /// `bar` widget within `foo` (i.e. `foo/bar`), you should provide + /// `/bar` to this function. If you want to render the index widget, just + /// use `/` or the empty string (leading forward slashes will automatically + /// be normalized). + pub fn widget<H: Html>( + &self, + cx: Scope, + // This is a `PurePath`, meaning it *does not* have a locale or the capsule name! + path: &str, + props: P, + ) -> View<H> { + self.__widget(cx, path, props, false) + } + /// An alternative to `.widget()` that delays the rendering of the widget + /// until the rest of the page has loaded. + /// + /// Normally, a widget will have its state generated at the earliest + /// possible opportunity (e.g. if it only uses build state, it will be + /// generated at build-time, but one using request state would have to + /// wait until request-time) and its contents prerendered with the pages + /// that use it. However, sometimes, you may have a particularly 'heavy' + /// widget that involves a large amount of state. If you're finding a + /// certain page is loading a bit slowly due to such a widget, then you + /// may wish to use `DelayedWidget` instead, which will generate state + /// as usual, but, when it comes time to actually render the widget in + /// this page, a placeholder will be inserted, and the whole widget will + /// only be rendered on the browser-side with an asynchronous fetch of + /// the state. + /// + /// Usually, you won't need to delay a widget, and choosing to use this over + /// `.widget()` should be based on real-world testing. + /// + /// Note that using other widgets inside a delayed widget will cause those + /// other widgets to be delayed in this context. Importantly, a widget + /// that is delayed in one page can be non-delayed in another page: + /// think of widgets as little modules that are imported into pages. + /// Delaying is just one importing strategy, by that logic. In fact, one + /// of the reasons you may wish to delay a widget's load is if it has a + /// very large nesting of depdendencies, which would slow down + /// server-side processing (although fetching on the browser-side will + /// almost always be quite a bit slower). Again, you should + /// base your choices with delaying on empirical data! + pub fn delayed_widget<H: Html>(&self, cx: Scope, path: &str, props: P) -> View<H> { + self.__widget(cx, path, props, true) + } + + /// The internal widget component logic. Note that this ignores scope + /// disposers entirely, as all scopes used are children of the given, + /// which is assumed to be the page-level scope. As such, widgets will + /// automatically be cleaned up with pages. + /// + /// # Node Types + /// This method is implemented on the `Capsule`, which is already associated + /// with a node type, however, in order for this to be usable with lazy + /// statics, which cannot have type parameters, one must create a lazy + /// static for the engine-side using `SsrNode`, and another for the + /// browser-side using `DomNode`/`HydrateNode` + /// (through `BrowserNodeType`). However, since Sycamore is unaware of these + /// target- gated distinctions, it will cause Rust to believe the types + /// may be out of sync. Hence, this function uses a shadow parameter `H` + /// with the same bounds as `G`, and confirms that the two are equal, + /// then performing a low-cost byte-level copy and transmutation to + /// assert the types as equal for the compiler. + /// + /// As a result, it is impossible to render widgets to a string in the + /// browser. + /// + /// The `transmute_copy` performed is considered cheap because it either + /// copies `&self`, or `&Arc<ErrorView<G>>`, both of which use + /// indirection internally, meaning only pointers are every copied. This + /// stands in contrast with the approach of copying entire `View`s, + /// which leads to worse performance as the compexity of the views grows. + #[allow(unused_variables)] + fn __widget<H: Html>(&self, cx: Scope, path: &str, props: P, delayed: bool) -> View<H> { + assert_eq!( + TypeId::of::<H>(), + TypeId::of::<G>(), + "mismatched render backends" + ); + + // Handle leading and trailing slashes + let path = path.strip_prefix('/').unwrap_or(path); + let path = path.strip_suffix('/').unwrap_or(path); + + // This will also add `__capsule/` implicitly + let path = format!("{}/{}", self.inner.get_path(), path); + // This is needed for index widgets + let path = path.strip_suffix('/').unwrap_or(&path); + let path = PathWithoutLocale(path.to_string()); + + #[cfg(not(target_arch = "wasm32"))] + return { + let mut view = View::empty(); + if delayed { + // SAFETY: We asserted that `G == H` above. + let self_copy: &Capsule<H, P> = unsafe { std::mem::transmute_copy(&&self) }; + // On the engine-side, delayed widgets should just render their + // fallback views + let fallback_fn = self_copy.fallback.as_ref().unwrap(); + create_child_scope(cx, |child_cx| { + view = (fallback_fn)(child_cx, props); + }); + } else { + view = self.engine_widget(cx, path, props); + } + + view + }; + // On the browser-side, delayed and non-delayed are the same (it just matters as + // to what's been preloaded) + #[cfg(target_arch = "wasm32")] + return { + let view = self.browser_widget(cx, path, props); + view + }; + } + + /// The internal browser-side logic for widgets, both delayed and not. + /// + /// See `.__widget()` for explanation of transmutation. + #[cfg(target_arch = "wasm32")] + fn browser_widget<H: Html>(&self, cx: Scope, path: PathWithoutLocale, props: P) -> View<H> { + use crate::{ + errors::ClientInvariantError, + path::PathMaybeWithLocale, + reactor::Reactor, + router::{match_route, FullRouteInfo, FullRouteVerdict}, + template::PreloadInfo, + }; + assert_eq!( + TypeId::of::<H>(), + TypeId::of::<G>(), + "mismatched render backends" + ); + + let reactor = Reactor::<G>::from_cx(cx); + // SAFETY: We asserted that `G == H` above. + let reactor: &Reactor<H> = unsafe { std::mem::transmute_copy(&reactor) }; + // This won't panic, because widgets won't be rendered until the initial laod is + // ready for them + let locale = reactor.get_translator().get_locale(); + let full_path = PathMaybeWithLocale::new(&path, &locale); + // This has the locale, and is used as the identifier for the calling page in + // the PSS. This will be `Some(..)` as long as we're not running in an error + // page (in which case we should immediately terminate anyway) or the like. + let caller_path = reactor + .router_state + .get_path() + .expect("tried to include widget in bad environment (probably an error view)"); + + // Figure out route information for this + let path_segments = full_path + .split('/') + .filter(|s| !s.is_empty()) + .collect::<Vec<&str>>(); // This parsing is identical to the Sycamore router's + let verdict = match_route( + &path_segments, + &reactor.render_cfg, + &reactor.entities, + &reactor.locales, + ); + + match verdict.into_full(&reactor.entities) { + FullRouteVerdict::Found(FullRouteInfo { + path: _, + entity, + was_incremental_match, + locale, + }) => { + // We have the capsule we want as `self`, but we also need to run the routing + // algorithm to handle incremental matching and localization. + // Obviously, the router should return the same capsule as we + // actually have, otherwise there would be some *seriously* weird stuff going + // on! If you're seeing this as a user, my best suggestion is + // that you might have two templates that somehow overlap: e.g. + // `foo/bar` and `gloo/bar`. You might have used `GLOO.widget()`, + // but that somehow put out `foo/bar` as the path. This should not be possible, + // and will, unless you have seriously modified the router or + // other internals, indicate a Perseus bug: please report this! + debug_assert_eq!(entity.get_path(), self.inner.get_path()); + + // SAFETY: We asserted that `G == H` above. + let self_copy: &Capsule<H, P> = unsafe { std::mem::transmute_copy(&self) }; + match self_copy.render_widget_for_template_client( + full_path, + caller_path, + props, + cx, + PreloadInfo { + locale, + was_incremental_match, + }, + ) { + Ok(view) => view, + Err(err) => reactor.error_views.handle_widget(err, cx), + } + } + // Widgets are all resolved on the server-side, meaning they are checked then too (be it + // at build-time or request-time). If this happpens, the user is rendering + // an invalid widget on the browser-side only. + _ => reactor.error_views.handle_widget( + ClientInvariantError::BadWidgetRouteMatch { + path: (*path).to_string(), + } + .into(), + cx, + ), + } + } + + /// The internal engine-side logic for widgets. + /// + /// See `.widget()` for explanation of transmutation. + #[cfg(not(target_arch = "wasm32"))] + fn engine_widget<H: Html>(&self, cx: Scope, path: PathWithoutLocale, props: P) -> View<H> { + use std::sync::Arc; + + use crate::error_views::ErrorViews; + use crate::errors::{ClientError, ServerError, StoreError}; + use crate::path::PathMaybeWithLocale; + use crate::reactor::{Reactor, RenderMode, RenderStatus}; + use crate::state::TemplateState; + use futures::executor::block_on; + use sycamore::prelude::*; + assert_eq!( + TypeId::of::<H>(), + TypeId::of::<G>(), + "mismatched render backends" + ); + + // This will always be rendered with access to the Perseus render context, which + // we will be working with a lot! + let reactor = Reactor::<G>::from_cx(cx); + match &reactor.render_mode { + RenderMode::Build { + render_status, + widget_render_cfg, + immutable_store, + widget_states, + possibly_incremental_paths, + } => { + // If the render status isn't good, don't even bother proceeding, and fail-fast + // instead + if !matches!(*render_status.borrow(), RenderStatus::Ok) { + return View::empty(); + } + + // Check if we're in the render config (which will just contain widgets at this + // point, since they're built first, and the rendering we're in now + // for templates is executed afterward) + if let Some(capsule_name) = widget_render_cfg.get(&*path) { + // Make sure this capsule would be safe for building + // If this were an incrementally generated widget, we wouldn't have even gotten + // this far, as it wouldn't be in the render config + if self.inner.uses_request_state() || self.inner.revalidates() { + *render_status.borrow_mut() = RenderStatus::Cancelled; + View::empty() + } else { + // This won't panic, because the reactor has been fully instantiated with a + // translator on the engine-side (unless we're in an error + // page, which is totally invalid) + let locale = reactor.get_translator().get_locale(); + // Get the path in a way we can work with + let path_encoded = format!( + "{}-{}", + &locale, + // The user provided this + urlencoding::encode(&path) + ); + // Since this widget has state built at build-time that will never change, + // it *must* be in the immutable store (only + // revalidating states go into the mutable store, + // and this would be `false` in the map if it + // revalidated!). The immutable store is really just + // a filesystem API, and we have no choice + // but to block here. + let state = match block_on( + immutable_store.read(&format!("static/{}.json", path_encoded)), + ) { + Ok(state) => state, + // If there's no state file, we'll assume an empty state + Err(StoreError::NotFound { .. }) => "null".to_string(), + Err(err) => { + *render_status.borrow_mut() = RenderStatus::Err(err.into()); + return View::empty(); + } + }; + let state = match TemplateState::from_str(&state) { + Ok(state) => state, + Err(err) => { + *render_status.borrow_mut() = + RenderStatus::Err(ServerError::InvalidPageState { + source: err, + }); + return View::empty(); + } + }; + + // Add this to the list of widget states so they can be written for later + // use + widget_states.borrow_mut().insert( + path.to_string(), + (capsule_name.to_string(), state.state.clone()), + ); + + // SAFETY: We asserted above that `G == H`. + let self_copy: &Capsule<H, P> = unsafe { std::mem::transmute_copy(&self) }; + match self_copy.render_widget_for_template_server( + PathMaybeWithLocale::new(&path, &locale), + state, + props, + cx, + ) { + Ok(view) => view, + Err(err) => { + *render_status.borrow_mut() = + RenderStatus::Err(ServerError::ClientError(err)); + View::empty() + } + } + } + } else { + // Either this widget can be incrementally generated, or it doesn't exist. We'll + // yield to the build process, which will build this if it's incremental, and + // just throw an error if it's not. + // + // Note that reschedulings can't arise from this, as incremental generation is + // a flexible pattern: it can be either build-time or request-time. Only request + // state or revalidation can trigger that. + possibly_incremental_paths.borrow_mut().push(path); + // We don't change the render status, because that would prevent other widgets + // from loading (and there might be multiple incrementals). + View::empty() + } + } + // Note: this will only happen for initial loads. + RenderMode::Request { + widget_states, + error_views, + unresolved_widget_accumulator, + } => { + // SAFETY: We asserted above that `G == H`. + let error_views: &Arc<ErrorViews<H>> = + unsafe { std::mem::transmute_copy(&error_views) }; + // This won't panic, because the reactor has been fully instantiated with a + // translator on the engine-side (unless we're in an error page, + // which is totally invalid) + let locale = reactor.get_translator().get_locale(); + let full_path = PathMaybeWithLocale::new(&path, &locale); + // Check if we've already built this widget (i.e. are we up to this layer, or a + // later one?) + match widget_states.get(&full_path) { + Some(res) => match res { + // There were no problems with getting the state + Ok(state) => { + // SAFETY: We asserted above that `G == H`. + let self_copy: &Capsule<H, P> = + unsafe { std::mem::transmute_copy(&self) }; + // Use that to render the widget for the server-side (this should *not* + // create a new reactor) + match self_copy.render_widget_for_template_server( + full_path, + state.clone(), + props, + cx, + ) { + Ok(view) => view, + // We'll render any errors to the whole widget, even if they might + // be internal (but they *really* + // shouldn't be, since those + // should've been handled when trying to fetch + // the state, as there's no active syste etc. on the engine-side) + Err(err) => error_views.handle_widget(err, cx), + } + } + // We're to render an error page with the given error data (which will not + // impact the rest of the page). Since this whole `Request` + // variant can only happen for initial loads, and since this is a + // `ServerError`, we'll make this take up the + // widget. + Err(err_data) => { + let err = ClientError::ServerError { + status: err_data.status, + message: err_data.msg.to_string(), + }; + + error_views.handle_widget(err, cx) + } + }, + None => { + // Just add this path to the list of unresolved ones, and it will be + // resolved in time for the next pass + unresolved_widget_accumulator.borrow_mut().push(path); + View::empty() + } + } + } + RenderMode::Head => panic!("widgets cannot be used in heads"), + RenderMode::Error => panic!("widgets cannot be used in error views"), + // This would be exceptionally weird... + RenderMode::Headers => panic!("widgets cannot be used in headers"), + } + } +} diff --git a/packages/perseus/src/translator/fluent.rs b/packages/perseus/src/translator/fluent.rs index ad37f1f472..d053a50021 100644 --- a/packages/perseus/src/translator/fluent.rs +++ b/packages/perseus/src/translator/fluent.rs @@ -1,4 +1,4 @@ -use crate::translator::errors::*; +use crate::{reactor::Reactor, translator::errors::*, PerseusNodeType}; use fluent_bundle::{bundle::FluentBundle, FluentArgs, FluentResource}; use intl_memoizer::concurrent::IntlLangMemoizer; use std::sync::Arc; @@ -76,7 +76,8 @@ impl FluentTranslator { /// 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 { - format!("{}{}", self.locale, url) + let url = url.strip_prefix('/').unwrap_or(url); + format!("{}/{}", self.locale, url) } /// Gets the locale for which this instance is configured. pub fn get_locale(&self) -> String { @@ -195,19 +196,25 @@ pub type TranslationArgs<'args> = FluentArgs<'args>; /// The internal Fluent backend for the `t!` macro. #[doc(hidden)] pub fn t_macro_backend(id: &str, cx: Scope) -> String { - let translator = use_context::<Signal<super::Translator>>(cx).get_untracked(); + // This `G` doesn't actually need to match up at all, but we do need to find the + // right type + let translator = use_context::<Reactor<PerseusNodeType>>(cx).get_translator(); translator.translate(id, None) } /// The internal Fluent backend for the `t!` macro, when it's used with /// arguments. #[doc(hidden)] pub fn t_macro_backend_with_args(id: &str, args: FluentArgs, cx: Scope) -> String { - let translator = use_context::<Signal<super::Translator>>(cx).get_untracked(); + // This `G` doesn't actually need to match up at all, but we do need to find the + // right type + let translator = use_context::<Reactor<PerseusNodeType>>(cx).get_translator(); translator.translate(id, Some(args)) } /// The internal Fluent backend for the `link!` macro. #[doc(hidden)] pub fn link_macro_backend(url: &str, cx: Scope) -> String { - let translator = use_context::<Signal<super::Translator>>(cx).get_untracked(); + // This `G` doesn't actually need to match up at all, but we do need to find the + // right type + let translator = use_context::<Reactor<PerseusNodeType>>(cx).get_translator(); translator.url(url) } diff --git a/packages/perseus/src/translator/lightweight.rs b/packages/perseus/src/translator/lightweight.rs index a6eb69c7fa..c0506f7541 100644 --- a/packages/perseus/src/translator/lightweight.rs +++ b/packages/perseus/src/translator/lightweight.rs @@ -1,4 +1,6 @@ +use crate::reactor::Reactor; use crate::translator::errors::*; +use crate::PerseusNodeType; use std::collections::HashMap; use sycamore::prelude::{use_context, Scope, Signal}; @@ -62,7 +64,8 @@ impl LightweightTranslator { /// 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 { - format!("{}{}", self.locale, url) + let url = url.strip_prefix('/').unwrap_or(url); + format!("{}/{}", self.locale, url) } /// Gets the locale for which this instance is configured. pub fn get_locale(&self) -> String { @@ -143,19 +146,25 @@ impl TranslationArgs { /// The internal lightweight backend for the `t!` macro. #[doc(hidden)] pub fn t_macro_backend(id: &str, cx: Scope) -> String { - let translator = use_context::<Signal<super::Translator>>(cx).get_untracked(); + // This `G` doesn't actually need to match up at all, but we do need to find the + // right type + let translator = use_context::<Reactor<PerseusNodeType>>(cx).get_translator(); translator.translate(id, None) } /// The internal lightweight backend for the `t!` macro, when it's used with /// arguments. #[doc(hidden)] pub fn t_macro_backend_with_args(id: &str, args: TranslationArgs, cx: Scope) -> String { - let translator = use_context::<Signal<super::Translator>>(cx).get_untracked(); + // This `G` doesn't actually need to match up at all, but we do need to find the + // right type + let translator = use_context::<Reactor<PerseusNodeType>>(cx).get_translator(); translator.translate(id, Some(args)) } /// The internal lightweight backend for the `link!` macro. #[doc(hidden)] pub fn link_macro_backend(url: &str, cx: Scope) -> String { - let translator = use_context::<Signal<super::Translator>>(cx).get_untracked(); + // This `G` doesn't actually need to match up at all, but we do need to find the + // right type + let translator = use_context::<Reactor<PerseusNodeType>>(cx).get_translator(); translator.url(url) } diff --git a/packages/perseus/src/turbine/build.rs b/packages/perseus/src/turbine/build.rs new file mode 100644 index 0000000000..bd5224bf47 --- /dev/null +++ b/packages/perseus/src/turbine/build.rs @@ -0,0 +1,669 @@ +use super::Turbine; +use crate::{ + errors::*, + i18n::{TranslationsManager, Translator}, + init::PerseusAppBase, + path::*, + plugins::PluginAction, + reactor::{RenderMode, RenderStatus}, + router::{match_route, FullRouteVerdict}, + server::get_path_slice, + state::{BuildPaths, StateGeneratorInfo, TemplateState}, + stores::MutableStore, + template::Entity, + utils::{minify, ssr_fallible}, +}; +use futures::{ + future::{try_join_all, BoxFuture}, + FutureExt, +}; +use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc}; +use sycamore::web::SsrNode; + +impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> { + /// Builds your whole app for being run on a server. Do not use this + /// function if you want to export your app. + /// + /// This returns an `Arc<Error>`, since any errors are passed to plugin + /// actions for further processing. + pub async fn build(&mut self) -> Result<(), Arc<Error>> { + self.plugins + .functional_actions + .build_actions + .before_build + .run((), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + let res = self.build_internal(false).await; + if let Err(err) = res { + let err: Arc<Error> = Arc::new(err.into()); + self.plugins + .functional_actions + .build_actions + .after_failed_build + .run(err.clone(), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + + Err(err) + } else { + self.plugins + .functional_actions + .build_actions + .after_successful_build + .run((), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + + Ok(()) + } + } + + pub(super) async fn build_internal(&mut self, exporting: bool) -> Result<(), ServerError> { + let locales = self.locales.get_all(); + + // Build all the global states, for each locale, in parallel + let mut global_state_futs = Vec::new(); + for locale in locales.into_iter() { + global_state_futs + .push(self.build_global_state_for_locale(locale.to_string(), exporting)); + } + let global_states_by_locale = try_join_all(global_state_futs).await?; + let global_states_by_locale = HashMap::from_iter(global_states_by_locale.into_iter()); + // Cache these for use by every other template + self.global_states_by_locale = global_states_by_locale; + + let mut render_cfg = HashMap::new(); + + // Now build every capsule's state in parallel (capsules are never rendered + // outside a page) + let mut capsule_futs = Vec::new(); + for capsule in self.entities.values() { + if capsule.is_capsule { + capsule_futs.push(self.build_template_or_capsule(capsule, exporting)); + } + } + let capsule_render_cfg_frags = try_join_all(capsule_futs).await?; + // Add to the render config as appropriate + for fragment in capsule_render_cfg_frags.into_iter() { + render_cfg.extend(fragment.into_iter()); + } + + // We now update the render config with everything we learned from the capsule + // building so the actual renders of the pages can resolve the widgets + // they have (if they have one that's not in here, it wasn't even built, + // and would cancel the render). + self.render_cfg = render_cfg.clone(); + + // Now build every template's state in parallel + let mut template_futs = Vec::new(); + for template in self.entities.values() { + if !template.is_capsule { + template_futs.push(self.build_template_or_capsule(template, exporting)); + } + } + let template_render_cfg_frags = try_join_all(template_futs).await?; + // Add to the render config as appropriate + for fragment in template_render_cfg_frags.into_iter() { + render_cfg.extend(fragment.into_iter()); + } + + // Now write the render config to the immutable store + self.immutable_store + .write( + "render_conf.json", + &serde_json::to_string(&render_cfg).unwrap(), + ) + .await?; + self.render_cfg = render_cfg; + + // And build the HTML shell (so that this does the exact same thing as + // instantiating from files) + let html_shell = PerseusAppBase::<SsrNode, M, T>::get_html_shell( + self.index_view_str.to_string(), + &self.root_id, + &self.render_cfg, + &self.plugins, + ) + .await?; + self.html_shell = Some(html_shell); + + Ok(()) + } + /// Builds the global state for a given locale, returning a tuple of the + /// locale and the state generated. This will also write the global + /// state to the immutable store (there is no such thing as revalidation + /// for global state, and it is *extremely* unlikely that there ever will + /// be). + async fn build_global_state_for_locale( + &self, + locale: String, + exporting: bool, + ) -> Result<(String, TemplateState), ServerError> { + let gsc = &self.global_state_creator; + + if exporting && (gsc.uses_request_state() || gsc.can_amalgamate_states()) { + return Err(ExportError::GlobalStateNotExportable.into()); + } + + let global_state = if gsc.uses_build_state() { + // Generate the global state and write it to a file + let global_state = gsc.get_build_state(locale.clone()).await?; + self.immutable_store + .write( + // We put the locale at the end to prevent confusion with any pages + &format!("static/global_state_{}.json", &locale), + &global_state.state.to_string(), + ) + .await?; + global_state + } else { + // If there's no build-time handler, we'll give an empty state. This will + // be very unexpected if the user is generating at request-time, since all + // the pages they have at build-time will be unable to access the global state. + // We could either completely disable build-time rendering when there's + // request-time global state generation, or we could give the user smart + // errors and let them manage this problem themselves by gating their + // usage of global state at build-time, since `.try_get_global_state()` + // will give a clear `Ok(None)` at build-time. For speed, the latter + // approach has been chosen. + // + // This is one of the biggest 'gotchas' in Perseus, and is clearly documented! + TemplateState::empty() + }; + Ok((locale, global_state)) + } + /// This returns the fragment of the render configuration generated by this + /// template/capsule, including the additions of any extra widgets that + /// needed to be incrementally built ahead of time. + // Note we use page/template rhetoric here, but this could equally be + // widget/capsule + async fn build_template_or_capsule( + &self, + entity: &Entity<SsrNode>, + exporting: bool, + ) -> Result<HashMap<String, String>, ServerError> { + // If we're exporting, ensure that all the capsule's strategies are export-safe + // (not requiring a server) + if exporting + && (entity.revalidates() || + entity.uses_incremental() || + entity.uses_request_state() || + // We check amalgamation as well because it involves request state, even if that wasn't provided + entity.can_amalgamate_states()) + { + return Err(ExportError::TemplateNotExportable { + template_name: entity.get_path(), + } + .into()); + } + + let mut render_cfg_frag = HashMap::new(); + + // We extract the paths and extra state for rendering outside, but we handle the + // render config inside this block + let (paths, extra) = if entity.uses_build_paths() { + let BuildPaths { mut paths, extra } = entity.get_build_paths().await?; + + // Add all the paths to the render config (stripping erroneous slashes as we go) + for mut page_path in paths.iter_mut() { + // Strip any erroneous slashes + let stripped = page_path.strip_prefix('/').unwrap_or(page_path); + let mut stripped = stripped.to_string(); + page_path = &mut stripped; + + let full_path = format!("{}/{}", &entity.get_path(), &page_path); + // And perform another strip for index pages to work + let full_path = full_path.strip_suffix('/').unwrap_or(&full_path); + let full_path = full_path.strip_prefix('/').unwrap_or(full_path); + render_cfg_frag.insert(full_path.to_string(), entity.get_path()); + } + + // Now if the page uses ISR, add an explicit `/*` in there after the template + // root path. Incremental rendering requires build-time path generation. + if entity.uses_incremental() { + render_cfg_frag.insert(format!("{}/*", &entity.get_path()), entity.get_path()); + } + + (paths, extra) + } else { + // There's no facility to generate extra paths for this template, so it only + // renders itself + + // The render config should map the only page this generates to the template of + // the same name + render_cfg_frag.insert(entity.get_path(), entity.get_path()); + // No extra state, one empty path for the index + (vec![String::new()], TemplateState::empty()) + }; + // We write the extra state even if it's empty + self.immutable_store + .write( + &format!( + "static/{}.extra.json", + urlencoding::encode(&entity.get_path()) + ), + &extra.state.to_string(), + ) + .await?; + + // We now have a populated render config, so we should build each path in + // parallel for each locale, if we can. Yes, the function we're calling + // will also write the revalidation text, but, if you're not using build + // state or being basic, the you're using request state, which means + // revalidation is completely irrelevant, since you're revalidating on + // every load. + if entity.uses_build_state() || entity.is_basic() { + let mut path_futs = Vec::new(); + for path in paths.into_iter() { + for locale in self.locales.get_all() { + let path = PurePath(path.clone()); + // We created these from the same loop as we render each path for each locale + // from, so this is safe to `.unwrap()` + let global_state = self.global_states_by_locale.get(locale).unwrap().clone(); + path_futs.push(self.build_path_or_widget_for_locale( + path, + entity, + &extra, + locale, + global_state, + exporting, + false, + )); + } + } + // Extend the render configuration with any incrementally generated widgets + let render_cfg_exts = try_join_all(path_futs).await?; + for ext in render_cfg_exts { + render_cfg_frag.extend(ext.into_iter()); + } + } + + Ok(render_cfg_frag) + } + /// The path this accepts is the path *within* the entity, not including the + /// entity's name! It is assumed that the path provided to this function + /// has been stripped of extra leading/trailing forward slashes. This + /// returns a map of widgets that needed to be incrementally built ahead + /// of time to avoid rescheduling that should be added to the render + /// config. + /// + /// This function will do nothing for entities that are not either basic or + /// build-state-generating. + /// + /// This function is `super`-public because it's used to generate + /// incremental pages. Because of this, it also takes in the most + /// up-to-date global state. + pub(super) async fn build_path_or_widget_for_locale( + &self, + path: PurePath, + entity: &Entity<SsrNode>, + extra: &TemplateState, + locale: &str, + global_state: TemplateState, + exporting: bool, + // This is used in request-time incremental generation + force_mutable: bool, + ) -> Result<HashMap<String, String>, ServerError> { + let translator = self + .translations_manager + .get_translator_for_locale(locale.to_string()) + .await?; + + let full_path_without_locale = PathWithoutLocale(match entity.uses_build_paths() { + // Note the stripping of trailing `/`s here (otherwise index build paths fail) + true => { + let full = format!("{}/{}", &entity.get_path(), path.0); + let full = full.strip_suffix('/').unwrap_or(&full); + full.strip_prefix('/').unwrap_or(full).to_string() + } + // We don't want to concatenate the name twice if we don't have to + false => entity.get_path(), + }); + // Create the encoded path, which always includes the locale (even if it's + // `xx-XX` in a non-i18n app) + // + // BUG: insanely nested paths won't work whatsoever if the filename is too long, + // maybe hash instead? + let full_path_encoded = format!( + "{}-{}", + translator.get_locale(), + urlencoding::encode(&full_path_without_locale) + ); + // And we'll need the full path with the locale for the `PageProps` + // If it's `xx-XX`, we should just have it without the locale (this may be + // interacted with by users) + let locale = translator.get_locale(); + let full_path = PathMaybeWithLocale::new(&full_path_without_locale, &locale); + + // First, if this page revalidates, write a timestamp about when it was built to + // the mutable store (this will be updated to keep track) + if entity.revalidates_with_time() { + let datetime_to_revalidate = entity + .get_revalidate_interval() + .unwrap() + .compute_timestamp(); + // Note that different locales do have different revalidation schedules + self.mutable_store + .write( + &format!("static/{}.revld.txt", full_path_encoded), + &datetime_to_revalidate.to_string(), + ) + .await?; + } + + let state = if entity.is_basic() { + // We don't bother writing the state of basic entities + TemplateState::empty() + } else if entity.uses_build_state() { + let build_state = entity + .get_build_state(StateGeneratorInfo { + // IMPORTANT: It is very easy to break Perseus here; always make sure this is + // the pure path, without the template name! + // TODO Compat mode for v0.3.0x? + path: (*path).clone(), + locale: translator.get_locale(), + extra: extra.clone(), + }) + .await?; + // Write the state to the appropriate store (mutable if the entity revalidates) + let state_str = build_state.state.to_string(); + if force_mutable || entity.revalidates() { + self.mutable_store + .write(&format!("static/{}.json", full_path_encoded), &state_str) + .await?; + } else { + self.immutable_store + .write(&format!("static/{}.json", full_path_encoded), &state_str) + .await?; + } + + build_state + } else { + // There's nothing we can do with any other sort of template at build-time + return Ok(HashMap::new()); + }; + + // For templates (*not* capsules), we'll render the full content (with + // dependencies), and the head (which capsules don't have), provided + // it's not always going to be useless (i.e. if this uses request state) + if !entity.is_capsule && !entity.uses_request_state() { + // Render the head (which has no dependencies) + let head_str = + entity.render_head_str(state.clone(), global_state.clone(), &translator)?; + let head_str = minify(&head_str, true)?; + if force_mutable || entity.revalidates() { + self.mutable_store + .write( + &format!("static/{}.head.html", full_path_encoded), + &head_str, + ) + .await?; + } else { + self.immutable_store + .write( + &format!("static/{}.head.html", full_path_encoded), + &head_str, + ) + .await?; + } + + // This stores a list of widgets that are able to be incrementally generated, + // but that weren't in the build paths listing of their capsules. + // These were, however, used at build-time by another page, meaning + // they should be automatically built for convenience --- we can + // therefore avoid an unnecessary build reschedule. + // + // Note that incremental generation is entirely side-effect based, so this + // function call just internally maintains a series of additions to + // the render configuration to be added properly once we're out of + // all the `async`. This *could* lead to race conditions on the + // immutable store, but Tokio should handle this. + self.build_render( + entity, + full_path, + &full_path_encoded, + translator, + state, + global_state, + exporting, + force_mutable, + // Start off with what's already known + self.render_cfg.clone(), + ) + .await + } else { + Ok(HashMap::new()) + } + } + + /// This enables recursion for rendering incrementally rendered widgets + /// (ideally, users would include the widgets they want at build-time in + /// their build paths, but, if we don't do this, then unnecessary + /// build reschedulings would abound a little too much). + /// + /// Each iteration of this will return a map to extend the render + /// configuration with, but the function maintains its own internal + /// render configuration passed through arguments, because it's designed to + /// be part of the larger asynchronous build process, therefore modifying + /// the root-level render config is not safe until the end. + fn build_render<'a>( + &'a self, + entity: &'a Entity<SsrNode>, + full_path: PathMaybeWithLocale, + full_path_encoded: &'a str, + translator: Translator, + state: TemplateState, + global_state: TemplateState, + exporting: bool, + force_mutable: bool, + mut render_cfg: HashMap<String, String>, + ) -> BoxFuture<'a, Result<HashMap<String, String>, ServerError>> { + async move { + let (prerendered, render_status, widget_states, paps) = { + // Construct the render mode we're using, which is needed because we don't + // know what dependencies are in a page/widget until we actually render it, + // which means we might find some that can't be built at build-time. + let render_status = Rc::new(RefCell::new(RenderStatus::Ok)); + let widget_states = Rc::new(RefCell::new(HashMap::new())); + let possibly_incremental_paths = Rc::new(RefCell::new(Vec::new())); + let mode = RenderMode::Build { + render_status: render_status.clone(), + // Make sure what we have is passed through + widget_render_cfg: render_cfg.clone(), + immutable_store: self.immutable_store.clone(), + widget_states: widget_states.clone(), + possibly_incremental_paths: possibly_incremental_paths.clone(), + }; + + // Now prerender the actual content + let prerendered = ssr_fallible(|cx| { + entity.render_for_template_server( + full_path.clone(), + state.clone(), + global_state.clone(), + mode.clone(), + cx, + &translator, + ) + })?; + let render_status = render_status.take(); + + // With the prerender over, all references to this have been dropped + // TODO Avoid cloning everything here + let widget_states = (*widget_states).clone().into_inner(); + // let widget_states = Rc::try_unwrap(widget_states).unwrap().into_inner(); + // We know this is a `HashMap<String, (String, Value)>`, which will work + let widget_states = serde_json::to_string(&widget_states).unwrap(); + let paps = (*possibly_incremental_paths).clone().into_inner(); + + (prerendered, render_status, widget_states, paps) + }; + + // Check how the render went + match render_status { + RenderStatus::Ok => { + // `Ok` does not necessarily mean all is well: anything in `possibly_incremental_paths` + // constitutes a widget that could not be rendered because it wasn't in the + // render config (either needs to be incrementally rendered, or it doesn't exist). + if paps.is_empty() { + let prerendered = minify(&prerendered, true)?; + // Write that prerendered HTML to a static file (whose presence is used to + // indicate that this page/widget was fine to be built at + // build-time, and will not change at request-time; + // therefore this will be blindly returned at request-time). + // We also write a JSON file with a map of all the widget states, since the + // browser will need to know them for hydration. + if force_mutable || entity.revalidates() { + self.mutable_store + .write(&format!("static/{}.html", full_path_encoded), &prerendered) + .await?; + self.mutable_store + .write( + &format!("static/{}.widgets.json", full_path_encoded), + &widget_states, + ) + .await?; + } else { + self.immutable_store + .write(&format!("static/{}.html", full_path_encoded), &prerendered) + .await?; + self.immutable_store + .write( + &format!("static/{}.widgets.json", full_path_encoded), + &widget_states, + ) + .await?; + } + // In this path, we haven't accumulated any extensions to the render config, and we can jsut return + // whatever we were given (thereby preserving the effect of previous recursions) + Ok(render_cfg) + } else { + let mut futs = Vec::new(); + for path in paps { + let locale = translator.get_locale(); + let render_cfg = render_cfg.clone(); + let global_state = global_state.clone(); + futs.push(async move { + // It's also possible that these widgets just don't exist, so check that + let localized_path = PathMaybeWithLocale::new(&path, &locale); + let path_slice = get_path_slice(&localized_path); + let verdict = match_route( + &path_slice, + &render_cfg, + &self.entities, + &self.locales, + ); + + match verdict.into_full(&self.entities) { + FullRouteVerdict::Found(route_info) => { + let capsule_name = route_info.entity.get_path(); + // This will always exist + let capsule_extra = match self + .immutable_store + .read(&format!( + "static/{}.extra.json", + urlencoding::encode(&capsule_name) + )) + .await + { + Ok(state) => { + TemplateState::from_str(&state).map_err(|err| ServerError::InvalidBuildExtra { + template_name: capsule_name.clone(), + source: err, + })? + } + // If this happens, then the immutable store has been tampered with, since + // the build logic generates some kind of state for everything + Err(_) => { + return Err(ServerError::MissingBuildExtra { + template_name: capsule_name, + }) + } + }; + + // The `path` is a `PathWithoutLocale`, and we need to strip the capsule name. + // Because this is produced from the widget component in the first place, it should + // be perfectly safe to unwrap everything here (any failures indicate users hand-rolling + // widgets or a Perseus bug). + let pure_path = path + .strip_prefix(&capsule_name) + .expect("couldn't strip capsule name from widget (unless you're hand-rolling widgets, this is a Perseus bug)"); + let pure_path = pure_path.strip_prefix('/').unwrap_or(pure_path); + let pure_path = PurePath(pure_path.to_string()); + + // This will perform the same process as we've done, recursing as necessary (yes, double recursion), + // and it will return a set of render configuration extenstions too. This does NOT use the render + // configuration internally, so we can avoid infinite loops. + let exts = self.build_path_or_widget_for_locale( + pure_path, + route_info.entity, + &capsule_extra, + &locale, + global_state.clone(), + exporting, + // It's incremental generation, but this will *end up* acting + // like it was in build paths all along, so don't force the mutable + // store unless we're being asked to from a higher level + force_mutable, + ) + .await?; + + let mut render_cfg_ext = HashMap::new(); + + render_cfg_ext.extend(exts.into_iter()); + // Now add this actual capsule itself + render_cfg_ext.insert(path.0, capsule_name); + + Ok(render_cfg_ext) + } + FullRouteVerdict::LocaleDetection(_) => { + return Err(ServerError::ResolveDepLocaleRedirection { + locale: locale.to_string(), + widget: path.to_string(), + }) + } + FullRouteVerdict::NotFound { .. } => return Err(ServerError::ResolveDepNotFound { widget: path.to_string(), locale: locale.to_string() }), + } + }); + } + let render_cfg_exts = try_join_all(futs).await?; + // Add all those extensions to our internal copy, which will be used for recursion + for ext in render_cfg_exts { + render_cfg.extend(ext.into_iter()); + } + + + // We've rendered all the possibly incremental widgets, failing if any were actually just nonexistent. + // However, because building a widget means building its state, not prerendering it, we don't know + // if we're going to have to do all this again because one of the widgets has yet another incremental + // dependency. So, restart the whole render process! + self.build_render(entity, full_path, full_path_encoded, translator, state, global_state, exporting, force_mutable, render_cfg).await + } + } + RenderStatus::Err(err) => Err(err), + // One of the dependencies couldn't be built at build-time, + // so, by not writing a prerender to the store, we implicitly + // reschedule it (unless this hasn't been allowed by the user, + // or if we're exporting). + // + // Important: this will **not** be returned for pages including + // incremental widgets that haven't been built yet, those are handled + // through `Ok`. Potentially non-existent widgets will also be handled + // through there. + RenderStatus::Cancelled => { + if exporting { + Err(ExportError::DependenciesNotExportable { + template_name: entity.get_path(), + } + .into()) + } else if !entity.can_be_rescheduled { + Err(ServerError::TemplateCannotBeRescheduled { + template_name: entity.get_path(), + }) + } else { + // It's fine to reschedule later, so just return the widgets we have + Ok(render_cfg) + } + } + } + }.boxed() + } +} diff --git a/packages/perseus/src/turbine/build_error_page.rs b/packages/perseus/src/turbine/build_error_page.rs new file mode 100644 index 0000000000..9d59204069 --- /dev/null +++ b/packages/perseus/src/turbine/build_error_page.rs @@ -0,0 +1,49 @@ +use super::Turbine; +use crate::error_views::ServerErrorData; +use crate::i18n::TranslationsManager; +use crate::stores::MutableStore; +use crate::translator::Translator; + +impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> { + /// 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). + /// + /// If a translator and translations string is provided, it will be assumed + /// to be of the correct locale, and will be injected into the page. A + /// best effort should be made to provide translations here. + /// + /// # Pitfalls + /// If a translations string is provided here that does not match with the + /// locale actually being returned (i.e. that which the client will + /// infer), there will be a mismatch between the translations string and + /// the locale, which can only be rectified by the user manually + /// switching to another locale and back again. Please ensure the + /// correct translations string is provided here! + pub(crate) fn build_error_page( + &self, + data: ServerErrorData, + // Translator and translations string + i18n_data: Option<(&Translator, &str)>, + ) -> String { + let (translator, translations_str) = if let Some((t, s)) = i18n_data { + (Some(t), Some(s)) + } else { + (None, None) + }; + + let (head, body) = self.error_views.render_to_string(data.clone(), translator); + + self.html_shell + .as_ref() + .unwrap() + .clone() + // This will inject the translations string if it's available + .error_page(&data, &body, &head, translations_str) + .to_string() + } +} diff --git a/packages/perseus/src/turbine/export.rs b/packages/perseus/src/turbine/export.rs new file mode 100644 index 0000000000..b02c965b00 --- /dev/null +++ b/packages/perseus/src/turbine/export.rs @@ -0,0 +1,394 @@ +use super::Turbine; +use crate::{ + error_views::ServerErrorData, + errors::*, + i18n::TranslationsManager, + internal::{PageData, PageDataPartial}, + path::PathMaybeWithLocale, + plugins::PluginAction, + state::TemplateState, + stores::MutableStore, + utils::get_path_prefix_server, +}; +use fs_extra::dir::{copy as copy_dir, CopyOptions}; +use futures::future::{try_join, try_join_all}; +use serde_json::Value; +use std::{collections::HashMap, fs, path::PathBuf, sync::Arc}; + +impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> { + /// Exports your app to a series of static files. If any templates/capsules + /// in your app use request-time-only functionality, this will fail. + pub async fn export(&mut self) -> Result<(), Arc<Error>> { + // Note that this function uses different plugin actions from a pure build + self.plugins + .functional_actions + .export_actions + .before_export + .run((), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + let res = self.build_internal(true).await; // We mark that we will be exporting + if let Err(err) = res { + let err: Arc<Error> = Arc::new(err.into()); + self.plugins + .functional_actions + .export_actions + .after_failed_build + .run(err.clone(), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + + return Err(err); + } else { + self.plugins + .functional_actions + .export_actions + .after_successful_build + .run((), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + } + + // By now, the global states have been written for each locale, along with the + // render configuration (that's all in memory and in the immutable store) + + // This won't have any trailing slashes (they're stripped by the immutable store + // initializer) + let dest = format!("{}/exported", self.immutable_store.get_path()); + // Turn the build artifacts into self-contained static files + let export_res = self.export_internal().await; + if let Err(err) = export_res { + let err: Arc<Error> = Arc::new(err.into()); + self.plugins + .functional_actions + .export_actions + .after_failed_export + .run(err.clone(), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + + Err(err) + } else { + self.copy_static_aliases(&dest)?; + self.copy_static_dir(&dest)?; + + self.plugins + .functional_actions + .export_actions + .after_successful_export + .run((), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + + Ok(()) + } + } + + // TODO Warnings for render cancellations in exported apps + async fn export_internal(&self) -> Result<(), ServerError> { + // Loop over every pair in the render config + let mut export_futs = Vec::new(); + for (path, template_path) in self.render_cfg.iter() { + export_futs.push(self.export_path(path, template_path)); + } + // If we're using i18n, loop through the locales to create translations files + let mut translations_futs = Vec::new(); + if self.locales.using_i18n { + for locale in self.locales.get_all() { + translations_futs.push(self.create_translation_file(locale)); + } + } + + // Do *everything* in parallel + try_join(try_join_all(export_futs), try_join_all(translations_futs)).await?; + + // Copying in bundles from the filesystem is done externally to this function + + Ok(()) + } + /// This exports for all locales, or for none if the app doesn't use i18n. + async fn export_path(&self, path: &str, template_path: &str) -> Result<(), ServerError> { + // We assume we've already built the app, which would have populated this + let html_shell = self.html_shell.as_ref().unwrap(); + + let path_prefix = get_path_prefix_server(); + // We need the encoded path to reference flattened build artifacts + // But we don't create a flattened system with exporting, everything is properly + // created in a directory structure + let path_encoded = urlencoding::encode(path).to_string(); + // All initial load pages should be written into their own folders, which + // prevents a situation of a template root page outside the directory for the + // rest of that template's pages (see #73). The `.html` file extension is + // added when this variable is used (for contrast to the `.json`s) + let initial_load_path = if path.ends_with("index") { + // However, if it's already an index page, we don't want `index/index.html` + path.to_string() + } else { + format!("{}/index", &path) + }; + + // Get the template itself + let template = self.entities.get(template_path); + let template = match template { + Some(template) => template, + None => { + return Err(ServeError::PageNotFound { + path: template_path.to_string(), + } + .into()) + } + }; + // Create a locale detection file for it if we're using i18n + // These just send the app shell, which will perform a redirect as necessary + // Notably, these also include fallback redirectors if either Wasm or JS is + // disabled (or both) + if self.locales.using_i18n { + self.immutable_store + .write( + &format!("exported/{}.html", &initial_load_path), + &html_shell + .clone() + .locale_redirection_fallback(&format!( + "{}/{}/{}", + path_prefix, self.locales.default, &path + )) + .to_string(), + ) + .await?; + } + // Check if that template uses build state (in which case it should have a JSON + // file) + let has_state = template.uses_build_state(); + if self.locales.using_i18n { + // Loop through all the app's locales + for locale in self.locales.get_all() { + // This map was constructed from the locales, so each one must be in here + let global_state = self.global_states_by_locale.get(locale).unwrap(); + + let page_data = self + .get_static_page_data(&format!("{}-{}", locale, &path_encoded), has_state) + .await?; + + // Don't create initial load pages for widgets + if !template.is_capsule { + // Get the translations string for this locale + let translations = self + .translations_manager + .get_translations_str_for_locale(locale.to_string()) + .await?; + // Create a full HTML file from those that can be served for initial loads + // The build process writes these with a dummy default locale even though we're + // not using i18n + let full_html = html_shell + .clone() + .page_data(&page_data, global_state, &translations) + .to_string(); + self.immutable_store + .write( + &format!("exported/{}/{}.html", locale, initial_load_path), + &full_html, + ) + .await?; + } + + // Serialize the page data to JSON and write it as a partial (fetched by the app + // shell for subsequent loads) + let partial_page_data = PageDataPartial { + state: page_data.state, + head: page_data.head, + }; + let partial = serde_json::to_string(&partial_page_data).unwrap(); + self.immutable_store + .write( + &format!("exported/.perseus/page/{}/{}.json", locale, &path), + &partial, + ) + .await?; + } + } else { + // For apps without i18n, the global state will still be built for the dummy + // locale + let global_state = self.global_states_by_locale.get("xx-XX").unwrap(); + + let page_data = self + .get_static_page_data( + &format!("{}-{}", self.locales.default, &path_encoded), + has_state, + ) + .await?; + + // Don't create initial load pages for widgets + if !template.is_capsule { + // Create a full HTML file from those that can be served for initial loads + // The build process writes these with a dummy default locale even though we're + // not using i18n + let full_html = html_shell + .clone() + .page_data(&page_data, global_state, "") + .to_string(); + // We don't add an extension because this will be queried directly by the + // browser + self.immutable_store + .write(&format!("exported/{}.html", initial_load_path), &full_html) + .await?; + } + + // Serialize the page data to JSON and write it as a partial (fetched by the app + // shell for subsequent loads) + let partial_page_data = PageDataPartial { + state: page_data.state, + head: page_data.head, + }; + let partial = serde_json::to_string(&partial_page_data).unwrap(); + self.immutable_store + .write( + &format!( + "exported/.perseus/page/{}/{}.json", + self.locales.default, &path + ), + &partial, + ) + .await?; + } + + Ok(()) + } + async fn create_translation_file(&self, locale: &str) -> Result<(), ServerError> { + // Get the translations string for that + let translations_str = self + .translations_manager + .get_translations_str_for_locale(locale.to_string()) + .await?; + // Write it to an asset so that it can be served directly + self.immutable_store + .write( + &format!("exported/.perseus/translations/{}", locale), + &translations_str, + ) + .await?; + + Ok(()) + } + async fn get_static_page_data( + &self, + full_path_encoded: &str, + has_state: bool, + ) -> Result<PageData, ServerError> { + // Get the partial HTML content and a state to go with it (if applicable) + let content = self + .immutable_store + .read(&format!("static/{}.html", full_path_encoded)) + .await?; + // This maps all the dependencies for any page that has a prerendered fragment + let widget_states = self + .immutable_store + .read(&format!("static/{}.widgets.json", full_path_encoded)) + .await?; + let widget_states = match serde_json::from_str::< + HashMap<PathMaybeWithLocale, Result<Value, ServerErrorData>>, + >(&widget_states) + { + Ok(widget_states) => widget_states, + Err(err) => return Err(ServerError::InvalidPageState { source: err }), + }; + let head = self + .immutable_store + .read(&format!("static/{}.head.html", full_path_encoded)) + .await?; + let state = match has_state { + true => serde_json::from_str( + &self + .immutable_store + .read(&format!("static/{}.json", full_path_encoded)) + .await?, + ) + .map_err(|err| ServerError::InvalidPageState { source: err })?, + false => TemplateState::empty().state, + }; + // Create an instance of `PageData` + Ok(PageData { + content, + state, + head, + widget_states, + }) + } + /// Copies the static aliases into a distribution directory at `dest` (no + /// trailing `/`). This should be the root of the destination directory for + /// the exported files. Because this provides a customizable + /// destination, it is fully engine-agnostic. + /// + /// The error type here is a tuple of the location the asset was copied + /// from, the location it was copied to, and the error in that process + /// (which could be from `io` or `fs_extra`). + fn copy_static_aliases(&self, dest: &str) -> Result<(), Arc<Error>> { + // Loop through any static aliases and copy them in too + // Unlike with the server, these could override pages! + // We'll copy from the alias to the path (it could be a directory or a file) + // Remember: `alias` has a leading `/`! + for (alias, path) in &self.static_aliases { + let from = PathBuf::from(path); + let to = format!("{}{}", dest, alias); + + if from.is_dir() { + if let Err(err) = copy_dir(&from, &to, &CopyOptions::new()) { + let err = EngineError::CopyStaticAliasDirErr { + source: err, + to, + from: path.to_string(), + }; + let err: Arc<Error> = Arc::new(err.into()); + self.plugins + .functional_actions + .export_actions + .after_failed_static_alias_dir_copy + .run(err.clone(), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + return Err(err); + } + } else if let Err(err) = fs::copy(&from, &to) { + let err = EngineError::CopyStaticAliasFileError { + source: err, + to, + from: path.to_string(), + }; + let err: Arc<Error> = Arc::new(err.into()); + self.plugins + .functional_actions + .export_actions + .after_failed_static_alias_file_copy + .run(err.clone(), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + return Err(err); + } + } + + Ok(()) + } + /// Copies the directory containing static data to be put in + /// `/.perseus/static/` (URL). This takes in both the location of the + /// static directory and the destination directory for exported files. + fn copy_static_dir(&self, dest: &str) -> Result<(), Arc<Error>> { + // Copy the `static` directory into the export package if it exists + // If the user wants extra, they can use static aliases, plugins are unnecessary + // here + if self.static_dir.exists() { + if let Err(err) = copy_dir( + &self.static_dir, + format!("{}/.perseus/", dest), + &CopyOptions::new(), + ) { + let err = EngineError::CopyStaticDirError { + source: err, + path: self.static_dir.to_string_lossy().to_string(), + dest: dest.to_string(), + }; + let err: Arc<Error> = Arc::new(err.into()); + self.plugins + .functional_actions + .export_actions + .after_failed_static_copy + .run(err.clone(), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + return Err(err); + } + } + + Ok(()) + } +} diff --git a/packages/perseus/src/turbine/export_error_page.rs b/packages/perseus/src/turbine/export_error_page.rs new file mode 100644 index 0000000000..0d96cf94d9 --- /dev/null +++ b/packages/perseus/src/turbine/export_error_page.rs @@ -0,0 +1,62 @@ +use super::Turbine; +use crate::error_views::ServerErrorData; +use crate::{errors::*, i18n::TranslationsManager, plugins::PluginAction, stores::MutableStore}; +use std::fs; +use std::sync::Arc; + +impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> { + /// Exports the error page of the given exit code to the given path. + pub async fn export_error_page(&self, code: u16, output: &str) -> Result<(), Arc<Error>> { + self.plugins + .functional_actions + .export_error_page_actions + .before_export_error_page + .run((code, output.to_string()), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + + // Build that error page as the server does (assuming the app has been + // built so that the HTML shell is ready) + let err_page_str = self.build_error_page( + ServerErrorData { + status: code, + // Hopefully, this error will appear in a context that makes sense (e.g. a 404). + // Exporting a 500 page doesn't make a great deal of sense on most + // static serving infrastructure (they'll have their own). + msg: "app was exported, no further details available".to_string(), + }, + // Localizing exported error pages is not currently supported. However, if a locale is + // available in the browser, it will be used to override whatever was + // rendered from this. + None, + ); + + // Write that to the given output location (this will be relative to wherever + // the user executed from) + match fs::write(output, err_page_str) { + Ok(_) => (), + Err(err) => { + let err = EngineError::WriteErrorPageError { + source: err, + dest: output.to_string(), + }; + let err: Arc<Error> = Arc::new(err.into()); + self.plugins + .functional_actions + .export_error_page_actions + .after_failed_write + .run(err.clone(), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + return Err(err); + } + }; + + self.plugins + .functional_actions + .export_error_page_actions + .after_successful_export_error_page + .run((), self.plugins.get_plugin_data()) + .map_err(|err| Arc::new(err.into()))?; + + Ok(()) + } +} diff --git a/packages/perseus/src/turbine/mod.rs b/packages/perseus/src/turbine/mod.rs new file mode 100644 index 0000000000..612588fc32 --- /dev/null +++ b/packages/perseus/src/turbine/mod.rs @@ -0,0 +1,160 @@ +//! The internals of Perseus' state generation platform. This is not responsible +//! for the reactivity of state, or any other browser-side work. This is +//! responsible for the actual *generation* of state on the engine-side, at both +//! build-time and request-time. +//! +//! If you wanted to isolate the core of engine-side Perseus, it would be this +//! module. + +mod build; +mod build_error_page; +mod export; +mod export_error_page; +mod serve; +/// This has the actual API endpoints. +mod server; +mod tinker; + +pub use server::{ApiResponse, SubsequentLoadQueryParams}; + +use crate::{ + error_views::ErrorViews, + errors::*, + i18n::{Locales, TranslationsManager}, + init::{PerseusAppBase, Tm}, + plugins::Plugins, + server::HtmlShell, + state::{GlobalStateCreator, TemplateState}, + stores::{ImmutableStore, MutableStore}, + template::EntityMap, +}; +use futures::executor::block_on; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use sycamore::web::SsrNode; + +/// The Perseus state generator. +#[derive(Debug)] +pub struct Turbine<M: MutableStore, T: TranslationsManager> { + /// All the templates and capsules in the app. + entities: EntityMap<SsrNode>, + /// The app's error views. + error_views: Arc<ErrorViews<SsrNode>>, + /// The app's locales data. + locales: Locales, + /// An immutable store. + immutable_store: ImmutableStore, + /// A mutable store. + mutable_store: M, + /// A translations manager. + translations_manager: T, + /// The global state creator. + global_state_creator: Arc<GlobalStateCreator>, + plugins: Arc<Plugins>, + index_view_str: String, + root_id: String, + /// This is stored as a `PathBuf` so we can easily check whether or not it + /// exists. + pub static_dir: PathBuf, + /// The app's static aliases. + pub static_aliases: HashMap<String, String>, + // --- These may not be populated at creation --- + /// The app's render configuration, a map of paths in the app to the names + /// of the templates that generated them. (Since templates can have + /// multiple `/` delimiters in their names.) + /// + /// Since the paths are not actually valid paths, we leave them typed as + /// `String`s, but these keys are in effect `PathWithoutLocale` instances. + render_cfg: HashMap<String, String>, + /// A map of locale to global state. This is kept cached throughout the + /// build process, since every template we build will require it to be + /// provided through context. + global_states_by_locale: HashMap<String, TemplateState>, + /// The HTML shell that can be used for constructing the full pages this app + /// returns. + html_shell: Option<HtmlShell>, +} + +// We want to be able to create a turbine straight from an app base +impl<M: MutableStore, T: TranslationsManager> TryFrom<PerseusAppBase<SsrNode, M, T>> + for Turbine<M, T> +{ + type Error = PluginError; + + fn try_from(app: PerseusAppBase<SsrNode, M, T>) -> Result<Self, Self::Error> { + let locales = app.get_locales()?; + let immutable_store = app.get_immutable_store()?; + let index_view_str = app.get_index_view_str(); + let root_id = app.get_root()?; + let static_aliases = app.get_static_aliases()?; + + Ok(Self { + entities: app.entities, + locales, + immutable_store, + mutable_store: app.mutable_store, + global_state_creator: app.global_state_creator, + plugins: app.plugins, + index_view_str, + root_id, + static_dir: PathBuf::from(&app.static_dir), + static_aliases, + error_views: app.error_views, + // This consumes the app + // Note that we can't do anything in parallel with this anyway + translations_manager: match app.translations_manager { + Tm::Dummy(tm) => tm, + Tm::Full(tm) => block_on(tm), + }, + + // If we're going from a `PerseusApp`, these will be filled in later + render_cfg: HashMap::new(), + global_states_by_locale: HashMap::new(), + html_shell: None, + }) + } +} + +impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> { + /// Updates some internal fields of the turbine by assuming the app has been + /// built in the past. This expects a number of things to exist in the + /// filesystem. Note that calling `.build()` will automatically perform + /// this population. + pub async fn populate_after_build(&mut self) -> Result<(), ServerError> { + // Get the render config + let render_cfg_str = self.immutable_store.read("render_conf.json").await?; + let render_cfg = serde_json::from_str::<HashMap<String, String>>(&render_cfg_str) + .map_err(|err| ServerError::BuildError(BuildError::RenderCfgInvalid { source: err }))?; + self.render_cfg = render_cfg; + + // Get all the global states + let mut global_states_by_locale = HashMap::new(); + for locale in self.locales.get_all() { + // IMPORTANT: A global state that doesn't generate at build-time won't have a + // corresponding file! + let res = self + .immutable_store + .read(&format!("static/global_state_{}.json", &locale)) + .await; + let global_state = match res { + Ok(state) => TemplateState::from_str(&state) + .map_err(|err| ServerError::InvalidPageState { source: err })?, + Err(StoreError::NotFound { .. }) => TemplateState::empty(), + Err(err) => return Err(err.into()), + }; + + global_states_by_locale.insert(locale.to_string(), global_state); + } + self.global_states_by_locale = global_states_by_locale; + + let html_shell = PerseusAppBase::<SsrNode, M, T>::get_html_shell( + self.index_view_str.to_string(), + &self.root_id, + &self.render_cfg, + &self.plugins, + ) + .await?; + self.html_shell = Some(html_shell); + + Ok(()) + } +} diff --git a/packages/perseus/src/turbine/serve.rs b/packages/perseus/src/turbine/serve.rs new file mode 100644 index 0000000000..981a55a8a7 --- /dev/null +++ b/packages/perseus/src/turbine/serve.rs @@ -0,0 +1,809 @@ +use chrono::{DateTime, Utc}; +use fmterr::fmt_err; +use futures::{ + future::{try_join_all, BoxFuture}, + FutureExt, +}; +use serde_json::Value; +use std::{cell::RefCell, collections::HashMap, rc::Rc}; +use sycamore::web::SsrNode; + +use super::Turbine; +use crate::{ + error_views::ServerErrorData, + reactor::RenderMode, + router::{match_route, FullRouteVerdict}, + template::Entity, +}; +use crate::{ + errors::*, + i18n::{TranslationsManager, Translator}, + internal::{PageData, PageDataPartial}, + path::*, + server::get_path_slice, + state::StateGeneratorInfo, + stores::MutableStore, + template::States, + Request, +}; +use crate::{ + state::{TemplateState, UnknownStateType}, + utils::ssr_fallible, +}; + +/// This is `PageDataPartial`, but it keeps the state as `TemplateState` for +/// internal convenience. +struct StateAndHead { + state: TemplateState, + head: String, +} + +impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> { + /// Gets the state for the given path. This will render the head, but it + /// will *not* render the contents, and, as a result, will not engage + /// with any dependencies this page/widget may have. If this is used to + /// get the state of a capsule, the head will of course be empty. + /// + /// This assumes the given locale is actually supported. + pub async fn get_state_for_path( + &self, + path: PathWithoutLocale, + locale: String, + entity_name: &str, + was_incremental: bool, + req: Request, + ) -> Result<PageDataPartial, ServerError> { + let translator = self + .translations_manager + .get_translator_for_locale(locale) + .await?; + let StateAndHead { state, head } = self + .get_state_for_path_internal( + path, + &translator, + entity_name, + was_incremental, + req, + None, + None, + false, /* This is not an initial load (so the browser will need a little `Ok` in + * the `Value`) */ + ) + .await?; + + Ok(PageDataPartial { + state: state.state, + head, + }) + } + /// Gets the full page data for the given path. This will generate the + /// state, render the head, and render the content of the page, + /// resolving all widget dependencies. + /// + /// This takes a translator to allow the caller to derive it in a way that + /// respects the likely need to know the translations string as well, + /// for error page interpolation. + /// + /// Like `.get_state_for_path()`, this returns the page data and the global + /// state in a tuple. + /// + /// # Pitfalls + /// This currently uses a layer-based dependency resolution algorithm, as a + /// widget may itself have widgets. However, the widgets a page/widget + /// uses may be dependent on its state, and therefore we cannot enumerate + /// the entire dependency tree without knowing all the states involved. + /// Therefore, we go layer-by-layer. Currently, we wait for each layer + /// to be fully complete before proceeding to the next one, which leads to + /// a layer taking as long as the longest state generation within it. This + /// can lead to poor render times when widgets are highly nested, a + /// pattern that should be avoided as much as possible. + /// + /// In future, this will build with maximal parallelism by not waiting for + /// each layer to be finished building before proceeding to the next + /// one. + pub async fn get_initial_load_for_path( + &self, + path: PathWithoutLocale, + translator: &Translator, + template: &Entity<SsrNode>, + was_incremental: bool, + req: Request, + ) -> Result<(PageData, TemplateState), ServerError> { + let locale = translator.get_locale(); + // Get the latest global state, which we'll share around + let global_state = self + .get_full_global_state_for_locale(&locale, clone_req(&req)) + .await?; + // Begin by generating the state for this page + let page_state = self + .get_state_for_path_internal( + path.clone(), + translator, + &template.get_path(), + was_incremental, + clone_req(&req), + Some(template), + Some(global_state.clone()), + true, // This is an initial load + ) + .await?; + + let path = PathWithoutLocale(path.strip_suffix('/').unwrap_or(&*path).to_string()); + // Yes, this is created twice; no, we don't care + // If we're interacting with the stores, this is the path this page/widget will + // be under + let path_encoded = format!("{}-{}", locale, urlencoding::encode(&path)); + + // The page state generation process will have updated any prerendered fragments + // of this page, which means they're guaranteed to be up-to-date. + // Importantly, if any of the dependencies weren't build-safe, or if the page + // uses request-state (which means, as explained above, we don't + // actually know what the dependencies are yet, let alone if they're + // build-safe), this fragment won't exist. Basically, if it exists, we + // can return it straight away with no extra work. Otherwise, we'll have to do a + // layer-by-layer render, which can handle non-build-safe dependencies. + // We call this a 'fragment' because it's not a complete HTML shell etc. (TODO?) + let prerendered_fragment_res = if template.revalidates() { + self.mutable_store + .read(&format!("static/{}.html", &path_encoded)) + .await + } else { + self.immutable_store + .read(&format!("static/{}.html", &path_encoded)) + .await + }; + // Propagate any errors, but if the asset wasn't found, then record that as + // `None` + let prerendered_fragment = match prerendered_fragment_res { + Ok(fragment) => Some(fragment), + Err(StoreError::NotFound { .. }) => None, + Err(err) => return Err(err.into()), + }; + + if let Some(prerendered_fragment) = prerendered_fragment { + // If there was a prerendered fragment, there will also be a record of the + // widget states we need to send to the client + let widget_states = if template.revalidates() { + self.mutable_store + .read(&format!("static/{}.widgets.json", &path_encoded)) + .await? + } else { + self.immutable_store + .read(&format!("static/{}.widgets.json", &path_encoded)) + .await? + }; + let widget_states = match serde_json::from_str::< + HashMap<PathMaybeWithLocale, (String, Value)>, + >(&widget_states) + { + Ok(widget_states) => widget_states, + Err(err) => return Err(ServerError::InvalidPageState { source: err }), + }; + Ok(( + PageData { + content: prerendered_fragment, + head: page_state.head, + state: page_state.state.state, + widget_states: widget_states + .into_iter() + // Discard the capsule names and create results (to match with the + // possibility of request-time failure) + .map(|(k, (_, v))| (k, Ok(v))) + .collect(), + }, + global_state, + )) + } else { + // This will block + let (final_widget_states, prerendered) = self + .render_all( + HashMap::new(), // This starts empty + path, + locale.to_string(), + page_state.state.clone(), + template, + global_state.clone(), + &req, + translator, + )? + .await?; + // Convert the `TemplateState`s into `Value`s + let final_widget_states = final_widget_states + .into_iter() + // We need to turn `TemplateState` into its underlying `Value` + .map(|(k, res)| (k, res.map(|s| s.state))) + .collect::<HashMap<_, _>>(); + + Ok(( + PageData { + content: prerendered, + head: page_state.head, + state: page_state.state.state, + widget_states: final_widget_states, + }, + global_state, + )) + } + } + /// Recurses through each layer of dependencies and eventually renders the + /// given page. + /// + /// This returns a tuple of widget states and the prerendered result. + /// + /// This is deliberately synchronous to avoid making `Self` `Sync`, which is + /// impossible with Perseus' current design. Thus, this blocks when + /// resolving each layer. + #[allow(clippy::too_many_arguments)] + #[allow(clippy::type_complexity)] + fn render_all<'a>( + &'a self, + // This is a map of widget paths to their states, which we'll populate as + // we go through. That way, we can just run the exact same render over and over + // again, getting to a new layer each time, since, if a widget finds its state in + // this, it'll use it. This will be progressively accumulated over many layers. + widget_states: HashMap<PathMaybeWithLocale, Result<TemplateState, ServerErrorData>>, + path: PathWithoutLocale, + locale: String, + state: TemplateState, + entity: &'a Entity<SsrNode>, // Recursion could make this either a template or a capsule + global_state: TemplateState, + req: &'a Request, + translator: &'a Translator, + ) -> Result< + BoxFuture< + 'a, + Result< + ( + HashMap<PathMaybeWithLocale, Result<TemplateState, ServerErrorData>>, + String, + ), + ServerError, + >, + >, + ServerError, + > { + // Misleadingly, this only has the locale if we're using i18n! + let full_path = PathMaybeWithLocale::new(&path, &locale); + + // We put this in an `Rc` so it can be put in the context and given to multiple + // widgets, but it will never be changed (we could have a lot of states + // here, so we want to minimize cloning where possible) + let widget_states_rc = Rc::new(widget_states); + // This will be used to store the paths of widgets that haven't yet been + // resolved. It will be cleared between layers. + let unresolved_widget_accumulator = Rc::new(RefCell::new(Vec::new())); + // Now we want to render the page in the dependency resolution mode (as opposed + // to the build mode, which just cancels the render if it finds any + // non-build-safe widgets). + let mode = RenderMode::Request { + widget_states: widget_states_rc.clone(), + error_views: self.error_views.clone(), + unresolved_widget_accumulator: unresolved_widget_accumulator.clone(), + }; + + // Start the first render. This registers all our mode stuff on `cx`, + // which is dropped when this is done. So, we can safely get the widget states + // back. + // Now prerender the actual content (a bit roundabout for error handling) + let prerendered = ssr_fallible(|cx| { + entity.render_for_template_server( + full_path.clone(), + state.clone(), + global_state.clone(), + mode.clone(), + cx, + translator, + ) + })?; + // // As explained above, this should never fail, because all references have + // been // dropped + // let mut widget_states = Rc::try_unwrap(widget_states_rc).unwrap(); + // TODO Avoid cloning here... + let mut widget_states = (*widget_states_rc).clone(); + + // We'll just have accumulated a ton of unresolved widgets, probably. If not, + // then we're done! If yes, we'll need to build all their states. + // TODO ...and here + let mut accumulator = (*unresolved_widget_accumulator).clone().into_inner(); + + let fut = async move { + if accumulator.is_empty() { + Ok((widget_states, prerendered)) + } else { + // First, deduplicate (relevant if the same widget is used more than once). We + // don't care about unstable sorting because these are strings. + accumulator.sort_unstable(); + accumulator.dedup(); + + let mut futs = Vec::new(); + for widget_path in accumulator.into_iter() { + let global_state = global_state.clone(); + let locale = locale.clone(); + futs.push(async move { + // Resolve the route + // Get a route verdict to determine the capsule this widget path maps to + let localized_widget_path = PathMaybeWithLocale::new(&widget_path, &locale); + let path_slice = get_path_slice(&localized_widget_path); + let verdict = match_route( + &path_slice, + &self.render_cfg, + &self.entities, + &self.locales, + ); + + let res = match verdict.into_full(&self.entities) { + FullRouteVerdict::Found(route_info) => { + let capsule_name = route_info.entity.get_path(); + + // Now build the state; if this fails, we won't fail the whole + // page, we'll just load an error for this particular widget + // (allowing the user to still see the rest of the page). If this + // sort of thing were to happen in a subsequent load, the browser + // would be responsible for this. + self.get_state_for_path_internal( + widget_path.clone(), + translator, + &capsule_name, + route_info.was_incremental_match, + clone_req(req), + // We do happen to actually have this from the routing + Some(route_info.entity), + Some(global_state), + true, /* This is an initial load, so don't put an `Ok` in + * the `Value` */ + ) + .await + // The error handling systems will need a client-style error, + // so we just make the same conversion that would be made on + // the browser-side + .map_err(|err| ServerErrorData { + status: err_to_status_code(&err), + msg: fmt_err(&err), + }) + // And discard the head (it's a widget) + .map(|state| state.state) + } + // This is just completely wrong, and implies a corruption, so it's made + // a page-level error + FullRouteVerdict::LocaleDetection(_) => { + return Err(ServerError::ResolveDepLocaleRedirection { + locale: locale.to_string(), + widget: widget_path.to_string(), + }) + } + // But a widget that isn't found will be made a widget-only error + FullRouteVerdict::NotFound { .. } => { + let err = ServerError::ResolveDepNotFound { + locale: locale.to_string(), + widget: widget_path.to_string(), + }; + Err(ServerErrorData { + status: err_to_status_code(&err), + msg: fmt_err(&err), + }) + } + }; + + // Return the tuples that'll go into `widget_states` + Ok((localized_widget_path, res)) + }); + } + let tuples = try_join_all(futs).await?; + widget_states.extend(tuples); + + // We've rendered this layer, and we're ready for the next one + self.render_all( + widget_states, + path, + locale, + state, + entity, + global_state, + req, + translator, + )? + .await + } + } + .boxed(); + Ok(fut) + } + + /// The internal version allows sharing a global state so we don't + /// constantly regenerate it in recursion. + /// + /// This assumes the given locale is supported. + #[allow(clippy::too_many_arguments)] + async fn get_state_for_path_internal( + &self, + path: PathWithoutLocale, /* This must not contain the locale, but it *will* contain the + * entity name */ + translator: &Translator, + entity_name: &str, + was_incremental: bool, + req: Request, + // If these are `None`, we'll generate them + entity: Option<&Entity<SsrNode>>, // Not for recursion, just convenience + global_state: Option<TemplateState>, + is_initial: bool, + ) -> Result<StateAndHead, ServerError> { + let locale = translator.get_locale(); + // This could be very different from the build-time global state + let global_state = match global_state { + Some(global_state) => global_state, + None => { + self.get_full_global_state_for_locale(&locale, clone_req(&req)) + .await? + } + }; + + let entity = match entity { + Some(entity) => entity, + None => self + .entities + .get(entity_name) + .ok_or(ServeError::PageNotFound { + path: path.to_string(), + })?, + }; + + let path = PathWithoutLocale(path.strip_suffix('/').unwrap_or(&*path).to_string()); + // If we're interacting with the stores, this is the path this page/widget will + // be under + let path_encoded = format!("{}-{}", locale, urlencoding::encode(&path)); + + // Any work we do with the build logic will expect the path without the template + // name, so we need to strip it (this could only fail if we'd mismatched + // the path to the entity name, which would be either a malformed + // request or a *critical* Perseus routing bug) + let pure_path = path + .strip_prefix(entity_name) + .ok_or(ServerError::TemplateNameNotInPath)?; + let pure_path = pure_path.strip_prefix('/').unwrap_or(pure_path); + let pure_path = PurePath(pure_path.to_string()); + + // If the entity is basic (i.e. has no state), bail early + if entity.is_basic() { + // Get the head (since this is basic, it has no state, and therefore + // this would've been written at build-time) + let head = if entity.is_capsule { + String::new() + } else { + self.immutable_store + .read(&format!("static/{}.head.html", &path_encoded)) + .await? + }; + + return Ok(StateAndHead { + // No, this state is never written anywhere at build-time + state: TemplateState::empty(), + head, + }); + } + + // No matter what we end up doing, we're probably going to need this (which will + // always exist) + let build_extra = match self + .immutable_store + .read(&format!( + "static/{}.extra.json", + urlencoding::encode(&entity.get_path()) + )) + .await + { + Ok(state) => { + TemplateState::from_str(&state).map_err(|err| ServerError::InvalidBuildExtra { + template_name: entity.get_path(), + source: err, + })? + } + // If this happens, then the immutable store has been tampered with, since + // the build logic generates some kind of state for everything + Err(_) => { + return Err(ServerError::MissingBuildExtra { + template_name: entity.get_path(), + }) + } + }; + // We'll need this too for any sort of state generation + let build_info = StateGeneratorInfo { + path: path.to_string(), + locale: locale.to_string(), + extra: build_extra.clone(), + }; + + // The aim of this next block is purely to ensure that whatever is in the + // im/mutable store is the latest and most valid version of the build + // state, if we're even using build state. + // + // Note that `was_incremental_match` will not be `true` for pages built + // with build paths, even if the template uses incremental generation. Thus, + // if it is `true`, we use the mutable store. + // + // If incremental and generated and not revalidating; get from *mutable*. + // If incremental and not generated; generate. + // If incremental and generated and revalidating; either get from mutable or + // revalidate. + // If not incremental and revalidating; either get from + // mutable or revalidate. + // If not incremental and not revalidating; get + // from immutable. + if was_incremental { + // If we have something in the mutable store, then this has already been + // generated + let res = self + .mutable_store + .read(&format!("static/{}.json", &path_encoded)) + .await; + // Propagate any errors, but if the asset wasn't found, then record that as + // `None` + let built_state = match res { + Ok(built_state) => Some(built_state), + Err(StoreError::NotFound { .. }) => None, + Err(err) => return Err(err.into()), + }; + + if built_state.is_some() { + // This has been generated already, so we need to check for the possibility of + // revalidation + let should_revalidate = self + .page_or_widget_should_revalidate( + &path_encoded, + entity, + build_info.clone(), + clone_req(&req), + ) + .await?; + if should_revalidate { + // We need to rebuild, which we can do with the build-time logic (which will use + // the mutable store) + self.build_path_or_widget_for_locale( + pure_path, + entity, + &build_extra, + &locale, + global_state.clone(), + false, + true, + ) + .await?; + } else { + // We don't need to revalidate, so whatever is in the + // mutable store is valid + } + } else { + // This is a new page, we need to actually generate it (which will handle any + // revalidation timestamps etc.). For this, we can use the usual + // build state logic, which will perform a full render, unless the + // dependencies aren't build-safe. Of course, we can guarantee if we're actually + // generating it now that it won't be revalidating. + // We can provide the most up-to-date global state to this. + self.build_path_or_widget_for_locale( + pure_path, + entity, + &build_extra, + &locale, + global_state.clone(), + false, + // This makes sure we use the mutable store no matter what (incremental) + true, + ) + .await?; + } + } else { + let should_revalidate = self + .page_or_widget_should_revalidate( + &path_encoded, + entity, + build_info.clone(), + clone_req(&req), + ) + .await?; + if should_revalidate { + // We need to rebuild, which we can do with the build-time logic + self.build_path_or_widget_for_locale( + pure_path, + entity, + &build_extra, + &locale, + global_state.clone(), + false, + false, + ) + .await?; + } else { + // We don't need to revalidate, so whatever is in the immutable + // store is valid + } + } + + // Whatever is in the im/mutable store is now valid and up-to-date, so fetch it + let build_state = if entity.uses_build_state() { + let state_str = if was_incremental || entity.revalidates() { + self.mutable_store + .read(&format!("static/{}.json", &path_encoded)) + .await? + } else { + self.immutable_store + .read(&format!("static/{}.json", &path_encoded)) + .await? + }; + TemplateState::from_str(&state_str) + .map_err(|err| ServerError::InvalidPageState { source: err })? + } else { + TemplateState::empty() + }; + + // Now get the request state if we're using it (of course, this must be + // re-generated for every request) + let request_state = if entity.uses_request_state() { + entity + .get_request_state(build_info.clone(), clone_req(&req)) + .await? + } else { + TemplateState::empty() + }; + + // Now handle the possibility of amalgamation + let states = States { + build_state, + request_state, + }; + let final_state = if states.both_defined() && entity.can_amalgamate_states() { + entity + .amalgamate_states(build_info, states.build_state, states.request_state) + .await? + } else if states.both_defined() && !entity.can_amalgamate_states() { + // We have both states, but can't amalgamate, so prioritze request state, as + // it's more personalized and more recent + states.request_state + } else { + // This only errors if both are defined, and we just checked that + states.get_defined().unwrap() + }; + + // We now need to render the head. Whatever is on the im/mutable store is the + // most up-to-date, and that won't have been written if we have an + // entity that uses request state (since it would always be invalid). + // Therefore, if we don't use request state, it'll be in the appropriate store, + // otherwise we'll need to render it ourselves. Of course, capsules + // don't have heads. + let head_str = if !entity.is_capsule { + if entity.uses_request_state() { + entity.render_head_str(final_state.clone(), global_state.clone(), translator)? + } else { + // The im/mutable store was updated by the last whole block (since any + // incremental generation or revalidation would have re-written + // the head if request state isn't being used) + if was_incremental || entity.revalidates() { + self.mutable_store + .read(&format!("static/{}.head.html", &path_encoded)) + .await? + } else { + self.immutable_store + .read(&format!("static/{}.head.html", &path_encoded)) + .await? + } + } + } else { + String::new() + }; + + // On the browser-side, widgets states will always be parsed as fallible, so, if + // this is from a capsule, we'll wrap it in `Ok` (working around this on + // the browser-side is more complex than a simple fix here) --- note + // that the kinds of `Err` variants on widget states that can be caused + // in the initial load process would just be returned directly as errors + // earlier from here (and would be accordingly handled on the browser-side). + // + // We should only do this on subsequent loads (initial loads do error handling + // more normally). + let final_state = if entity.is_capsule && !is_initial { + let val = final_state.state; + let ok_val = serde_json::to_value(Ok::<Value, ()>(val)).unwrap(); + TemplateState::from_value(ok_val) + } else { + final_state + }; + + Ok(StateAndHead { + state: final_state, + head: head_str, + }) + } + + /// Checks timestamps and runs user-provided logic to determine if the given + /// widget/path should revalidate at the present time. + async fn page_or_widget_should_revalidate( + &self, + path_encoded: &str, + entity: &Entity<SsrNode>, + build_info: StateGeneratorInfo<UnknownStateType>, + req: Request, + ) -> Result<bool, ServerError> { + let mut should_revalidate = false; + // If it revalidates after a certain period of time, we need to check that + // BEFORE the custom logic (clearly documented) + if entity.revalidates_with_time() { + // Get the time when it should revalidate (RFC 3339) + // This will be updated, so it's in a mutable store + let datetime_to_revalidate_str = self + .mutable_store + .read(&format!("static/{}.revld.txt", path_encoded)) + .await?; + let datetime_to_revalidate = DateTime::parse_from_rfc3339(&datetime_to_revalidate_str) + .map_err(|err| { + ServerError::ServeError(ServeError::BadRevalidate { source: err }) + })?; + // Get the current time (UTC) + let now = Utc::now(); + + // If the datetime to revalidate is still in the future, end with `false` (the + // custom logic is only executed if the time-based one passes) + if datetime_to_revalidate > now { + return Ok(false); + } + should_revalidate = true; + } + + // Now run the user's custom revalidation logic + if entity.revalidates_with_logic() { + should_revalidate = entity.should_revalidate(build_info, req).await?; + } + Ok(should_revalidate) + } + /// Gets the full global state from the state generated at build-time and + /// the generator itself. This assumes that the provided locale is + /// supported. + /// + /// This should only be called once per API call. + async fn get_full_global_state_for_locale( + &self, + locale: &str, + req: Request, + ) -> Result<TemplateState, ServerError> { + let gsc = &self.global_state_creator; + // We know the locale is supported + let built_state = self.global_states_by_locale.get(locale).unwrap(); + + let global_state = if gsc.uses_request_state() { + let req_state = gsc.get_request_state(locale.to_string(), req).await?; + // If we have a non-empty build-time state, we'll need to amalgamate + if !built_state.is_empty() { + if gsc.can_amalgamate_states() { + gsc.amalgamate_states(locale.to_string(), built_state.clone(), req_state) + .await? + } else { + // No amalgamation capability, request time state takes priority + req_state + } + } else { + req_state + } + } else { + // This global state is purely generated at build-time (or nonexistent) + built_state.clone() + }; + + Ok(global_state) + } +} + +/// Clones a `Request` from its internal parts. +fn clone_req(raw: &Request) -> Request { + let mut builder = Request::builder(); + + for (name, val) in raw.headers() { + builder = builder.header(name, val); + } + + builder + .uri(raw.uri()) + .method(raw.method()) + .version(raw.version()) + // We always use an empty body because, in a Perseus request, only the URI matters + // Any custom data should therefore be sent in headers (if you're doing that, consider a + // dedicated API) + .body(()) + .unwrap() // This should never fail... +} diff --git a/packages/perseus/src/turbine/server.rs b/packages/perseus/src/turbine/server.rs new file mode 100644 index 0000000000..a7de995d01 --- /dev/null +++ b/packages/perseus/src/turbine/server.rs @@ -0,0 +1,369 @@ +use super::Turbine; +use crate::{ + error_views::ServerErrorData, + errors::{err_to_status_code, ServerError}, + i18n::{TranslationsManager, Translator}, + path::{PathMaybeWithLocale, PathWithoutLocale}, + router::{match_route, FullRouteInfo, FullRouteVerdict}, + server::get_path_slice, + state::TemplateState, + stores::MutableStore, + utils::get_path_prefix_server, + Request, +}; +use fmterr::fmt_err; +use http::{ + header::{self, HeaderName}, + HeaderMap, HeaderValue, StatusCode, +}; +use serde::{Deserialize, Serialize}; + +/// The integration-agnostic representation of the response Perseus will give to +/// HTTP requests. +#[derive(Debug)] +pub struct ApiResponse { + /// The actual response body. + pub body: String, + /// The additional headers for the response. These will *not* include things + /// like caching directives and the like, as they are expected to be + /// handled by integrations. + pub headers: HeaderMap, + /// The HTTP status code of the response. + pub status: StatusCode, +} +impl ApiResponse { + /// Creates a 200 OK response with the given body and MIME type. + pub fn ok(body: &str) -> Self { + Self { + body: body.to_string(), + headers: HeaderMap::new(), + status: StatusCode::OK, + } + } + /// Creates a 404 Not Found response. + pub fn not_found(msg: &str) -> Self { + Self { + body: msg.to_string(), + headers: HeaderMap::new(), + status: StatusCode::NOT_FOUND, + } + } + /// Creates some other error response. + pub fn err(status: StatusCode, body: &str) -> Self { + Self { + body: body.to_string(), + headers: HeaderMap::new(), + status, + } + } + /// Adds the given header to this response. + pub fn add_header(&mut self, k: HeaderName, v: HeaderValue) { + self.headers.insert(k, v); + } + /// Sets the `Content-Type` HTTP header to the given MIME type, which tells + /// the browser what file type it has actually been given. For HTML, this is + /// especially important! + /// + /// As this is typically called last, and only once, it consumes `self` for + /// ergonomics. If this is not desired, the `.add_header()` method can + /// be manually invoked. + /// + /// # Panics + /// + /// This will panic if the given MIME type contains invalid ASCII + /// characters. + pub fn content_type(mut self, mime_type: &str) -> Self { + self.headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_str(mime_type).unwrap(), + ); + self + } +} + +/// The query parameters used in subsequent load requests. This is provided for +/// convenience, since the majority of servers have some kind of mechanism to +/// parse query parameters automatically into `struct`s. +#[derive(Serialize, Deserialize, Debug)] +pub struct SubsequentLoadQueryParams { + /// The name of the template or capsule the queried page or widget was + /// generated by (since this endpoint is called by the app shell, which + /// will have performed its own routing). + pub entity_name: String, + /// Whether or not this page or widget was an incremental match (returned by + /// the router). This is required internally. + pub was_incremental_match: bool, +} + +impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> { + /// The endpoint for getting translations. + /// + /// Translations have the `text/plain` MIME type, as they may be in an + /// entirely arbitrary format, which should be manually parsed. + pub async fn get_translations(&self, locale: &str) -> ApiResponse { + // Check if the locale is supported + if self.locales.is_supported(locale) { + let translations = self + .translations_manager + .get_translations_str_for_locale(locale.to_string()) + .await; + match translations { + Ok(translations) => ApiResponse::ok(&translations).content_type("text/plain"), + Err(err) => ApiResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &fmt_err(&err)), + } + } else { + ApiResponse::not_found("locale not supported") + } + } + + /// The endpoint for getting page/capsule data through the subsequent load + /// system. + /// + /// The path provided to this may have trailing slashes, these will be + /// handled. It is expected to end in `.json` (needed for compatibility + /// with the exporting system). + /// + /// Subsequent loads have the MIME type `application/json`. + pub async fn get_subsequent_load( + &self, + raw_path: PathWithoutLocale, + locale: String, + entity_name: String, + was_incremental_match: bool, + req: Request, + ) -> ApiResponse { + // Check if the locale is supported + if self.locales.is_supported(&locale) { + // Parse the path + let raw_path = raw_path.strip_prefix('/').unwrap_or(&raw_path); + let raw_path = raw_path.strip_suffix('/').unwrap_or(raw_path); + let path = PathWithoutLocale(match raw_path.strip_suffix(".json") { + Some(path) => path.to_string(), + None => { + return ApiResponse::err(StatusCode::BAD_REQUEST, "paths must end in `.json`") + } + }); + + let page_data_partial = self + .get_state_for_path(path, locale, &entity_name, was_incremental_match, req) + .await; + let page_data_partial = match page_data_partial { + Ok(partial) => partial, + Err(err) => { + // Parse the error to an appropriate status code + let status = StatusCode::from_u16(err_to_status_code(&err)).unwrap(); + let msg = fmt_err(&err); + return ApiResponse::err(status, &msg); + } + }; + + // We know the form of this, and it should never fail + let page_data_str = serde_json::to_string(&page_data_partial).unwrap(); + ApiResponse::ok(&page_data_str).content_type("application/json") + } else { + ApiResponse::not_found("locale not supported") + } + } + + /// The endpoint for getting the full HTML contents of a page with no round + /// trips (except for suspended states and/or delayed widgets). This is + /// what should be returned to the user when they first ask for a page + /// in the app. + /// + /// This expects to take a raw path without the locale split out that still + /// needs URL decoding. + /// + /// If there's an error anywhere in this function, it will return the HTML + /// of a proper error page. + /// + /// Initial loads *always* (even in the case of errors) have the MIME type + /// `text/html`. + pub async fn get_initial_load( + &self, + raw_path: PathMaybeWithLocale, + req: Request, + ) -> ApiResponse { + // Decode the URL so we can work with spaces and special characters + let raw_path = match urlencoding::decode(&raw_path) { + Ok(path) => path.to_string(), + Err(err) => { + return self.html_err( + 400, + fmt_err(&ServerError::UrlDecodeFailed { source: err }), + None, + ) + } + }; + let raw_path = PathMaybeWithLocale(raw_path.as_str().to_string()); + + // Run the routing algorithm to figure out what to do here + let path_slice = get_path_slice(&raw_path); + let verdict = match_route(&path_slice, &self.render_cfg, &self.entities, &self.locales); + match verdict.into_full(&self.entities) { + FullRouteVerdict::Found(FullRouteInfo { + path, + entity, + locale, + was_incremental_match, + }) => { + // Get the translations to interpolate into the page + let translations_str = self + .translations_manager + .get_translations_str_for_locale(locale.clone()) + .await; + let translations_str = match translations_str { + Ok(translations) => translations, + // We know for sure that this locale is supported, so there's been an internal + // server error if it can't be found + Err(err) => { + return self.html_err(500, fmt_err(&err), None); + } + }; + + // We can use those to get a translator efficiently + let translator = match self + .translations_manager + .get_translator_for_translations_str(locale, translations_str.clone()) + .await + { + Ok(translator) => translator, + // We need to give a proper translator to the error pages, which we can't + Err(err) => return self.html_err(500, fmt_err(&err), None), + }; + + // This returns both the page data and the most up-to-date global state + let res = self + .get_initial_load_for_path( + path, + &translator, + entity, + was_incremental_match, + req, + ) + .await; + let (page_data, global_state) = match res { + Ok(data) => data, + Err(err) => { + return self.html_err( + err_to_status_code(&err), + fmt_err(&err), + Some((&translator, &translations_str)), + ) + } + }; + + let final_html = self + .html_shell + .as_ref() + .unwrap() + .clone() + .page_data(&page_data, &global_state, &translations_str) + .to_string(); + // NOTE: Yes, the user can fully override the content type...I have yet to find + // a good use for this given the need to generate a `View` + // though... + let mut response = ApiResponse::ok(&final_html).content_type("text/html"); + + // Generate and add HTTP headers + let headers = match entity.get_headers( + TemplateState::from_value(page_data.state), + global_state, + Some(&translator), + ) { + Ok(headers) => headers, + // The pointlessness of returning an error here is well documented + Err(err) => { + return self.html_err( + err_to_status_code(&err), + fmt_err(&err), + Some((&translator, &translations_str)), + ) + } + }; + for (key, val) in headers { + response.add_header(key.unwrap(), val); + } + + response + } + FullRouteVerdict::LocaleDetection(redirect_path) => { + // TODO Parse the `Accept-Language` header and return a proper redirect + // Construct a locale redirection fallback + let html = self + .html_shell + .as_ref() + .unwrap() // We assume the app has been built + .clone() + .locale_redirection_fallback( + // This is the dumb destination we'd use if Wasm isn't enabled (the default + // locale). It has *zero* bearing on what the Wasm + // bundle will do. + &format!( + "{}/{}/{}", + get_path_prefix_server(), + &self.locales.default, + // This is a `PathWithoutLocale` + redirect_path.0, + ), + ) + .to_string(); + // TODO Headers? They weren't here in the old code... + // This isn't an error, but that's how this API expresses it (302 redirect) + ApiResponse::err(StatusCode::FOUND, &html).content_type("text/html") + } + // Any unlocalized 404s would go to a redirect first + FullRouteVerdict::NotFound { locale } => { + // Get the translations to interpolate into the page + let translations_str = self + .translations_manager + .get_translations_str_for_locale(locale.clone()) + .await; + let translations_str = match translations_str { + Ok(translations) => translations, + // We know for sure that this locale is supported, so there's been an internal + // server error if it can't be found + Err(err) => { + return self.html_err(500, fmt_err(&err), None); + } + }; + + // We can use those to get a translator efficiently + let translator = match self + .translations_manager + .get_translator_for_translations_str(locale, translations_str.clone()) + .await + { + Ok(translator) => translator, + // We need to give a proper translator to the error pages, which we can't + Err(err) => return self.html_err(500, fmt_err(&err), None), + }; + + self.html_err( + 404, + "page not found".to_string(), + Some((&translator, &translations_str)), + ) + } + } + } + + // TODO If we ever support error headers, this would be the place to do it; PRs + // welcome! + /// Creates an HTML error page for when the initial load handler needs one. + /// + /// This assumes that the app has already been actually built. + /// + /// # Panics + /// This will panic implicitly if the given status code is invalid. + fn html_err( + &self, + status: u16, + msg: String, + i18n_data: Option<(&Translator, &str)>, + ) -> ApiResponse { + let err_data = ServerErrorData { status, msg }; + let html = self.build_error_page(err_data, i18n_data); + // This can construct a 404 if needed + ApiResponse::err(StatusCode::from_u16(status).unwrap(), &html).content_type("text/html") + } +} diff --git a/packages/perseus/src/turbine/tinker.rs b/packages/perseus/src/turbine/tinker.rs new file mode 100644 index 0000000000..720edf7114 --- /dev/null +++ b/packages/perseus/src/turbine/tinker.rs @@ -0,0 +1,19 @@ +use super::Turbine; +use crate::{ + errors::PluginError, i18n::TranslationsManager, plugins::PluginAction, stores::MutableStore, +}; + +impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> { + /// Runs tinker plugin actions. + pub fn tinker(&self) -> Result<(), PluginError> { + // Run all the tinker actions + // Note: this is deliberately synchronous, tinker actions that need a + // multithreaded async runtime should probably be making their own engines! + self.plugins + .functional_actions + .tinker + .run((), self.plugins.get_plugin_data())?; + + Ok(()) + } +} diff --git a/packages/perseus/src/utils/async_fn_trait.rs b/packages/perseus/src/utils/async_fn_trait.rs index aeeb0d9987..d2a1860e04 100644 --- a/packages/perseus/src/utils/async_fn_trait.rs +++ b/packages/perseus/src/utils/async_fn_trait.rs @@ -3,6 +3,7 @@ use std::pin::Pin; /// A generic return type for asynchronous functions that we need to store in a /// struct. +#[doc(hidden)] pub type AsyncFnReturn<T> = Pin<Box<dyn Future<Output = T> + Send + Sync>>; /// Creates traits that prevent users from having to pin their functions' return @@ -11,15 +12,15 @@ pub type AsyncFnReturn<T> = Pin<Box<dyn Future<Output = T> + Send + Sync>>; #[doc(hidden)] macro_rules! make_async_trait { ( - $name:tt - // This is capable of supporting HRTBs, though that's no longer needed. Left in for future cases. - $(< $( $g_name:ident $( : $g_restr_1:tt $( + $g_restr_extra:tt )* $( - for<$g_lt:lifetime> $g_restr_hrtb:tt<$g_lt_1:lifetime> )* )? $(,)? )+ >)?, + $vis:vis $name:ident + // Because of `Into<GeneratorResult<T>>`, this is capable of supporting generics on trait bounds to two levels + $(< $( $g_name:ident $( : $g_restr_1:tt $( < $g_restr_1_g:ident $( < $g_restr_1_g_1:ident > )? > )? $( + $g_restr_extra:tt )* )? $(,)? )+ >)?, $return_ty:ty $(, $arg_name:ident: $arg:ty)* ) => { // These traits should be purely internal, the user is likely to shoot themselves in the foot #[doc(hidden)] - pub trait $name$( <$( $g_name $( : $g_restr_1 $( + $g_restr_extra )* $( + for<$g_lt> $g_restr_hrtb<$g_lt_1> )* )?, )*> )? { + pub trait $name$( <$( $g_name $( : $g_restr_1 $( < $g_restr_1_g $( < $g_restr_1_g_1 > )? > )? $( + $g_restr_extra )* )?, )*> )? { fn call( &self, // Each given argument is repeated @@ -28,7 +29,7 @@ macro_rules! make_async_trait { )* ) -> AsyncFnReturn<$return_ty>; } - impl<T, F, $($( $g_name $( : $g_restr_1 $( + $g_restr_extra )* $( + for<$g_lt> $g_restr_hrtb<$g_lt_1> )* )?, )*)?> $name$( <$( $g_name, )*> )? for T + impl<T, F, $($( $g_name $( : $g_restr_1 $( < $g_restr_1_g $( < $g_restr_1_g_1 > )? > )? $( + $g_restr_extra )* )?, )*)?> $name$( <$( $g_name, )*> )? for T where T: Fn( $( diff --git a/packages/perseus/src/utils/checkpoint.rs b/packages/perseus/src/utils/checkpoint.rs index b46adda73d..c90ecb6f4a 100644 --- a/packages/perseus/src/utils/checkpoint.rs +++ b/packages/perseus/src/utils/checkpoint.rs @@ -1,3 +1,5 @@ +use crate::reactor::WindowVariable; + /// Marks a checkpoint in the code and alerts any tests that it's been reached /// by creating an element that represents it. The preferred solution would be /// emitting a DOM event, but the WebDriver specification currently doesn't @@ -25,19 +27,13 @@ pub fn checkpoint(name: &str) { panic!("checkpoint must not contain hyphens, use underscores instead (hyphens are used as an internal delimiter)"); } - let val_opt = web_sys::window().unwrap().get("__PERSEUS_TESTING"); - let js_obj = match val_opt { - Some(js_obj) => js_obj, - None => return, - }; - // The object should only actually contain the string value that was injected - let is_testing = match js_obj.as_bool() { - Some(cfg_str) => cfg_str, - None => return, + let is_testing = WindowVariable::new_bool("__PERSEUS_TESTING"); + match is_testing { + WindowVariable::Some(val) if val => (), + // If the boolean was some other type in JS, just abort (this would be a *very* weird + // environment that implies user tampering) + _ => return, }; - if !is_testing { - return; - } // If we're here, we're testing // We dispatch a console warning to reduce the likelihood of literal 'testing in diff --git a/packages/perseus/src/utils/context.rs b/packages/perseus/src/utils/context.rs deleted file mode 100644 index 6ab63b94f0..0000000000 --- a/packages/perseus/src/utils/context.rs +++ /dev/null @@ -1,16 +0,0 @@ -use sycamore::prelude::{ - create_signal, provide_context_ref, try_use_context, use_context, Scope, Signal, -}; - -/// Adds the given value to the given reactive scope inside a `Signal`, -/// replacing a value of that type if one is already present. This returns a -/// reference to the `Signal` inserted. -pub(crate) fn provide_context_signal_replace<T: 'static>(cx: Scope, val: T) -> &Signal<T> { - if let Some(ctx) = try_use_context::<Signal<T>>(cx) { - ctx.set(val); - } else { - provide_context_ref(cx, create_signal(cx, val)); - } - - use_context(cx) -} diff --git a/packages/perseus/src/utils/fetch.rs b/packages/perseus/src/utils/fetch.rs index 57d5c9fd43..f12a7154b5 100644 --- a/packages/perseus/src/utils/fetch.rs +++ b/packages/perseus/src/utils/fetch.rs @@ -3,10 +3,10 @@ use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen_futures::JsFuture; use web_sys::{Request, RequestInit, RequestMode, Response}; -/// Fetches the given resource. This should NOT be used by end users, but it's -/// required by the CLI. -pub(crate) async fn fetch(url: &str) -> Result<Option<String>, ClientError> { - let js_err_handler = |err: JsValue| ClientError::Js(format!("{:?}", err)); +/// Fetches the given resource. This is heavily intertwined with the Perseus +/// error management system, and should not be used by end users. +pub(crate) async fn fetch(url: &str, ty: AssetType) -> Result<Option<String>, ClientError> { + let js_err_handler = |err: JsValue| FetchError::Js(format!("{:?}", err)); let mut opts = RequestInit::new(); opts.method("GET").mode(RequestMode::Cors); @@ -20,7 +20,8 @@ pub(crate) async fn fetch(url: &str) -> Result<Option<String>, ClientError> { // Turn that into a proper response object let res: Response = res_value.dyn_into().unwrap(); // If the status is 404, we should return that the request worked but no file - // existed + // existed (there is a `NotFound` error type, but that's only used for + // preloading) if res.status() == 404 { return Ok(None); } @@ -36,6 +37,7 @@ pub(crate) async fn fetch(url: &str) -> Result<Option<String>, ClientError> { None => { return Err(FetchError::NotString { url: url.to_string(), + ty, } .into()) } @@ -48,6 +50,7 @@ pub(crate) async fn fetch(url: &str) -> Result<Option<String>, ClientError> { url: url.to_string(), status: res.status(), err: body_str, + ty, } .into()) } diff --git a/packages/perseus/src/utils/minify.rs b/packages/perseus/src/utils/minify.rs index 6545b345c9..97e87280b4 100644 --- a/packages/perseus/src/utils/minify.rs +++ b/packages/perseus/src/utils/minify.rs @@ -7,6 +7,9 @@ use minify_html_onepass::{with_friendly_error, Cfg}; /// If the second argument is set to `false`, CSS and JS will not be minified, /// and the performance will be improved. pub(crate) fn minify(code: &str, minify_extras: bool) -> Result<String, ServerError> { + // TODO Minification seems to be currently breaking hydration? (Not certain of + // this though...) + return Ok(code.to_string()); // In case the user is using invalid HTML (very tricky error to track down), we // let them disable this feature if cfg!(feature = "minify") { diff --git a/packages/perseus/src/utils/mod.rs b/packages/perseus/src/utils/mod.rs index f686165849..525e29bad0 100644 --- a/packages/perseus/src/utils/mod.rs +++ b/packages/perseus/src/utils/mod.rs @@ -1,9 +1,9 @@ +#[cfg(not(target_arch = "wasm32"))] mod async_fn_trait; #[cfg(not(target_arch = "wasm32"))] mod cache_res; #[cfg(target_arch = "wasm32")] mod checkpoint; -mod context; mod decode_time_str; #[cfg(target_arch = "wasm32")] mod fetch; @@ -11,16 +11,17 @@ mod log; #[cfg(not(target_arch = "wasm32"))] mod minify; mod path_prefix; +mod render; #[cfg(target_arch = "wasm32")] mod replace_head; mod test; +#[cfg(not(target_arch = "wasm32"))] pub(crate) use async_fn_trait::AsyncFnReturn; #[cfg(not(target_arch = "wasm32"))] pub use cache_res::{cache_fallible_res, cache_res}; #[cfg(target_arch = "wasm32")] pub use checkpoint::checkpoint; -pub(crate) use context::provide_context_signal_replace; pub use decode_time_str::{ComputedDuration, InvalidDuration, PerseusDuration}; /* These have dummy equivalents for the browser */ #[cfg(target_arch = "wasm32")] pub(crate) use fetch::fetch; @@ -28,4 +29,8 @@ pub(crate) use fetch::fetch; pub(crate) use minify::minify; pub use path_prefix::*; #[cfg(target_arch = "wasm32")] +pub(crate) use render::render_or_hydrate; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use render::ssr_fallible; +#[cfg(target_arch = "wasm32")] pub(crate) use replace_head::replace_head; diff --git a/packages/perseus/src/utils/render.rs b/packages/perseus/src/utils/render.rs new file mode 100644 index 0000000000..dbeb28cd12 --- /dev/null +++ b/packages/perseus/src/utils/render.rs @@ -0,0 +1,103 @@ +#[cfg(target_arch = "wasm32")] +use sycamore::utils::render::insert; +#[cfg(all(not(feature = "hydrate"), target_arch = "wasm32"))] +use sycamore::web::DomNode; +#[cfg(not(target_arch = "wasm32"))] +use sycamore::web::SsrNode; +use sycamore::{prelude::Scope, view::View}; + +/// Renders or hydrates the given view to the given node, +/// depending on feature flags. This will atuomatically handle +/// proper scoping. +/// +/// This has the option to force a render by ignoring the initial elements. +/// +/// **Warning:** if hydration is being used, it is expected that +/// the given view was created inside a `with_hydration_context()` closure. +// TODO Make sure hydration will work when it's targeted at a blank canvas... +// XXX This is *highly* dependent on internal Sycamore implementation +// details! (TODO PR for `hydrate_to_with_scope` etc.) +#[cfg(target_arch = "wasm32")] +#[allow(unused_variables)] +pub(crate) fn render_or_hydrate( + cx: Scope, + view: View<crate::template::BrowserNodeType>, + parent: web_sys::Element, + force_render: bool, +) { + #[cfg(feature = "hydrate")] + { + use sycamore::web::HydrateNode; + + // We need `sycamore::hydrate_to_with_scope()`! + // --- Verbatim copy from Sycamore, changed for known scope --- + // Get children from parent into a View to set as the initial node value. + let mut children = Vec::new(); + let child_nodes = parent.child_nodes(); + for i in 0..child_nodes.length() { + children.push(child_nodes.get(i).unwrap()); + } + let children = children + .into_iter() + .map(|x| View::new_node(HydrateNode::from_web_sys(x))) + .collect::<Vec<_>>(); + + insert( + cx, + &HydrateNode::from_web_sys(parent.into()), + view, // We assume this was created in `with_hydration_context(..)` + if force_render { + None + } else { + Some(View::new_fragment(children)) + }, + None, + false, + ); + } + #[cfg(not(feature = "hydrate"))] + { + // We have to delete the existing content before we can render the new stuff + parent.set_inner_html(""); + insert( + cx, + &DomNode::from_web_sys(parent.into()), + view, + None, + None, + false, + ); + } +} + +/// Renders the given view to a string in a fallible manner, managing hydration +/// automatically. +// XXX This is *highly* dependent on internal Sycamore implementation +// details! +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn ssr_fallible<E>( + view_fn: impl FnOnce(Scope) -> Result<View<SsrNode>, E>, +) -> Result<String, E> { + use sycamore::web::WriteToString; + use sycamore::{prelude::create_scope_immediate, utils::hydrate::with_hydration_context}; // XXX This may become private one day! + + let mut ret = Ok(String::new()); + create_scope_immediate(|cx| { + // Usefully, this wrapper can return anything! + let view_res = with_hydration_context(|| view_fn(cx)); + match view_res { + Ok(view) => { + let mut view_str = String::new(); + for node in view.flatten() { + node.write_to_string(&mut view_str); + } + ret = Ok(view_str); + } + Err(err) => { + ret = Err(err); + } + } + }); + + ret +} diff --git a/scripts/test.rs b/scripts/test.rs index 04d07c9b96..acea3cbe8d 100644 --- a/scripts/test.rs +++ b/scripts/test.rs @@ -53,7 +53,15 @@ fn real_main() -> i32 { let stdout = String::from_utf8_lossy(&output.stdout).to_string(); // Extract the last line of that (the executable name) - stdout.lines().last().expect("couldn't get server executable (the build failed)").trim().to_string() + match stdout.lines().last() { + Some(last) => last.trim().to_string(), + // If the build fails, we need to know why + None => { + std::io::stderr().write_all(&output.stdout).unwrap(); + std::io::stderr().write_all(&output.stderr).unwrap(); + panic!("couldn't get server executable (the build failed, details are above)"); + } + } }; // Run the server from that executable in the background diff --git a/website/src/templates/docs/mod.rs b/website/src/templates/docs/mod.rs index b16783e81f..f4696f554e 100644 --- a/website/src/templates/docs/mod.rs +++ b/website/src/templates/docs/mod.rs @@ -6,4 +6,6 @@ mod icons; mod search_bar; mod template; +#[cfg(not(target_arch = "wasm32"))] +pub use get_file_at_version::get_file_at_version; pub use template::get_template; diff --git a/website/src/templates/index.rs b/website/src/templates/index.rs index 515b3b3938..12d62271a5 100644 --- a/website/src/templates/index.rs +++ b/website/src/templates/index.rs @@ -717,21 +717,31 @@ async fn get_build_state(_path: String, _locale: String) -> RenderFnResultWithCa // We know exactly where the examples we want are // TODO Join these futures separately let props = CodeExamples { - app_in_a_file: get_example("../examples/website/app_in_a_file/src/main.rs").await?, - state_generation: get_example("../examples/website/state_generation/src/main.rs").await?, - i18n: get_example("../examples/website/i18n/src/main.rs").await?, - cli: get_example("../examples/website/cli.txt").await?, - get_started: get_example("../examples/website/get_started.txt").await?, + app_in_a_file: get_example("examples/website/app_in_a_file/src/main.rs").await?, + state_generation: get_example("examples/website/state_generation/src/main.rs").await?, + i18n: get_example("examples/website/i18n/src/main.rs").await?, + cli: get_example("examples/website/cli.txt").await?, + get_started: get_example("examples/website/get_started.txt").await?, }; Ok(props) } +// Paths given to this function should be relative to the project root! #[cfg(not(target_arch = "wasm32"))] async fn get_example(path: &str) -> Result<Example, std::io::Error> { - use tokio::fs; + use super::docs::get_file_at_version; + use std::path::PathBuf; + + // Get each example file from the `stable` branch (that branch corresponds to an + // actual version, even if it's a beta, meaning the code is guaranteed to + // work for the user, otherwise the version wouldn't have passed pre-release + // checks). This also makes sure that there is a version in which the user + // can run the actual code they're seeing. Further, it means these examples can + // be fearlessly updated in `main` and PRs without sending users on a wild goose + // chase. + let raw = get_file_at_version(path, "stable", PathBuf::from("../"))?; - let raw = fs::read_to_string(path).await?; // Get rid of anything after a snip comment let snipped_parts = raw.split("// SNIP").collect::<Vec<_>>(); let full = snipped_parts[0].to_string();