diff --git a/.gitignore b/.gitignore index 96ef6c0b94..692bacdfe0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target Cargo.lock +pkg/ diff --git a/docs/next/src/SUMMARY.md b/docs/next/src/SUMMARY.md index 09b5b34977..6ad88c1e66 100644 --- a/docs/next/src/SUMMARY.md +++ b/docs/next/src/SUMMARY.md @@ -29,16 +29,17 @@ - [State Amalgamation](./strategies/amlagamation.md) - [CLI](./cli.md) - [Ejecting](./ejecting.md) -- [Config Managers](./config-managers.md) - [Testing](./testing/intro.md) - [Checkpoints](./testing/checkpoints.md) - [Fantoccini Basics](./testing/fantoccini-basics.md) - [Manual Testing](./testing/manual.md) - [Styling](./styling.md) +- [Stores](./stores.md) +- [Static Exporting](./exporting.md) - [Deploying](./deploying/intro.md) - - [Static Exporting](./deploying/exporting.md) - - [Server Deployment]() - - [Serverless Deployment]() + - [Server Deployment](./deploying/serverful.md) + - [Serverless Deployment](./deploying/serverless.md) + - [Optimizing Code Size](./deploying/size.md) - [Migrating from v0.1.x](./updating.md) *** # Advanced diff --git a/docs/next/src/cli.md b/docs/next/src/cli.md index 18bb2b623d..3502e46ba3 100644 --- a/docs/next/src/cli.md +++ b/docs/next/src/cli.md @@ -14,6 +14,18 @@ Builds your app in the same way as `build`, and then builds the Perseus server ( You can also provide `--no-build` to this command to make it skip building your app to Wasm and performing static generation. In this case, it will just build the serve rand run it (ideal for restarting the server if you've made no changes). +### `test` + +Exactly the same as `serve`, but runs your app in testing mode, which you can read more about [here](./testing/intro.md). + +### `export` + +Builds and exports your app to a series of purely static files at `.perseus/dist/exported/`. This will only work if your app doesn't use any strategies that can't be run at build time, but if that's the case, then you can easily use Perseus without a server after running this command! You can read more about static exporting [here](./exporting.md). + +### `deploy` + +Builds your app for production and places it in `pkg/`. You can then upload that folder to a server of your choosing to deploy your app live! You can (and really should) read more about deployment and the potential problems you may encounter [here](./deploying/intro.md). + ### `clean` This command is the solution to just about any problem in your app that doesn't make sense, it deletes the `.perseus/` directory entirely, which should remove any corruptions! If this doesn't work, then the problem is in your code (unless you just updated to a new version and now something doesn't work, then it's probably on us, please [open an issue](https://github.com/arctic-hen7/perseus)!). diff --git a/docs/next/src/config-managers.md b/docs/next/src/config-managers.md deleted file mode 100644 index 7865851db2..0000000000 --- a/docs/next/src/config-managers.md +++ /dev/null @@ -1,5 +0,0 @@ -# Config Managers - -Perseus generates a number of files in its build process, which allow it to make your app extremely performant on the client and the server. By default, these are stored under `.perseus/dist/`, however there may be cases in which you want to store these files elsewhere, particularly given that **they need to modified at runtime**. While not recommended, it may be necessary in some deployments to move these files to a database or CMS. Note that this will have a notable performance impact on your app, and the default is always recommended. - -That said, Perseus allows you to store content wherever you'd like with the `ConfigManager` trait, the default implementation of which is `FsConfigManager`, which takes a directory to use. Very similar to the [translations manager]() system, you can customize the options to this by providing your own instance of it under `define_app!`'s `config_manager` property, or you can build your own for interfacing with a non-filesystem storage apparatus. To do the latter, you'll need to consult [this file](https://github.com/arctic-hen7/perseus/blob/main/packages/perseus/src/config_manager.rs), and, if you're stuck, don't hesitate to ask a question under [discussions](https://github.com/arctic-hen7/perseus/discussions/new) on GitHub! diff --git a/docs/next/src/define-app.md b/docs/next/src/define-app.md index da9071c9f8..d2a40a6c8b 100644 --- a/docs/next/src/define-app.md +++ b/docs/next/src/define-app.md @@ -25,7 +25,8 @@ Here's a list of everything you can provide to the macro and what each one does - `default` -- the default locale of your app (e.g. `en-US`) - `other` -- a list of the other locales your app supports - `static_aliases` (optional) -- a list of aliases to static files in your project (e.g. for a favicon) -- `config_manager` (optional) -- a custom configuration manager +- `dist_path` (optional) -- a custom path to distribution artifacts (this is relative to `.perseus/`!) +- `mutable_store` (optional) -- a custom mutable store - `translations_manager` (optional) -- a custom translations manager **WARNING:** if you try to include something from outside the current directory in `static_aliases`, **no part of your app will load**! If you could include such content, you might end up serving `/etc/passwd`, which would be a major security risk. diff --git a/docs/next/src/deploying/intro.md b/docs/next/src/deploying/intro.md index eaedaa0b18..e1c5817ab5 100644 --- a/docs/next/src/deploying/intro.md +++ b/docs/next/src/deploying/intro.md @@ -1,5 +1,25 @@ # Deploying +> **WARNING:** although Perseus is technically ready for deployment, the system is not yet recommended for production! See [here](../what-is-perseus.md#how-stable-is-it) for more details. + Perseus is a complex system, but we aim to make deploying it as easy as possible. This section will describe a few different types of Perseus deployments, and how they can be managed. -*Note: Perseus deployment is still under design and development, so this information in particular is subject to rapid change before v1.0.0.* +## Release Mode + +The Perseus CLI supports the `--release` flag on the `build`, `serve`, and `export` commands. When you're preparing a production release of your app, be sure to use this flag! + +## `perseus deploy` + +If you haven't [ejected](../cli/ejecting.md), then you can prepare your app for deployment with a single command: `perseus deploy`. If you can use [static exporting](../exporting.md), then you should run `perseus deploy -e`, otherwise you should just use `perseus deploy`. + +This will create a new directory `pkg/` for you (you can change that by specifying `--output`) which will contain everything you need to deploy your app. That directory is entirely self-contained, and can be copied to an appropriate hosting provider for production deployment! + +Note that this command will run a number of optimizations in the background, including using the `--release` flag, but it won't try to aggressively minimize your Wasm code size. For tips on how to do that, see [here](./size.md). + +### Static Exporting + +If you use `perseus deploy -e`, the contents of `pkg/` can be served by any file host that can handle the [slight hiccup](../exporting.md#file-extensions) of file extensions. Locally, you can test this out with [`serve`](https://github.com/vercel/serve), a JavaScript package designed for this purpose. + +### Fully-Fledged Server + +If you just use `perseus deploy`, the `pkg/` directory will contain a binary called `server` for you to run, which will serve your app on its own. However, it's important to note that this binary is structured to support either the development configuration of running inside `.perseus/` or the production configuration of running inside `pkg/`, and you have to provide the `PERSEUS_STANDALONE` environment variable to tell it to do the latter. This binary can then be run on any server with a writable filesystem. For more details on this, see the next subsection. diff --git a/docs/next/src/deploying/serverful.md b/docs/next/src/deploying/serverful.md new file mode 100644 index 0000000000..4c37dcd8ff --- /dev/null +++ b/docs/next/src/deploying/serverful.md @@ -0,0 +1,27 @@ +# Server Deployment + +If your app uses rendering strategies that need a server, you won't be able to export your app to purely static files, and so you'll need to host the Perseus server itself. + +You can prepare your production server by running `perseus deploy`, which will create a new directory called `pkg/`, which will contain the standalone binary and everything needed to run it. You should then upload this file to your server and set the `PERSEUS_STANDALONE` environment variable to `true` so that Perseus expects a standalone binary configuration. Note that this process will vary depending on your hosting provider. + +## Hosting Providers + +As you may recall from [this section](../stores.md) on immutable and mutable stores, Perseus modifies some data at runtime, which is problematic if your hosting provider imposes the restriction that you can't write to the filesystem (as Netlify does). Perseus automatically handles this as well as it can by separating out mutable from immutable data, and storing as much as it can on the filesystem without causing problems. However, data for pages that use the *revalidation* or *incremental generation* strategies must be placed in a location where it can be changed while Perseus is running. + +If you're only using *build state* and/or *build paths* (or neither), you should export your app to purely static files instead, which you can read more about doing [here](../exporting.md). That will avoid this entire category of problems, and you can deploy basically wherever you want. + +If you're bringing *request state* into the mix, you can't export to static files, but you can run on a read-only filesystem, because only the *revalidation* and *incremental generation* strategies require mutability. Perseus will use a mutable store on the filesystem in the background, but won't ever need it. + +If you're using *revalidation* and *incremental generation*, you have two options, detailed below. + +### Writable Filesystems + +The first of these is to use an old-school provider that gives you a filesystem that you can write to. This may be more expensive for hosting, but it will allow you to take full advantage of all Perseus' features in a highly performant way. + +You can deploy to one of these providers without any further changes to your code, as they mimic your local system almost entirely (with a writable filesystem). Just run `perseus deploy` and copy the resulting `pkg/` folder to the server! + +### Alternative Mutable Stores + +The other option you have is deploying to a modern provider that has a read-only filesystem and then using an alternative mutable store. That is, you store your mutable data in a database or the like rather than on the filesystem. This requires you to implement the `MutableStore` `trait` for your storage system (see the [API docs](https://docs.rs/perseus)), which should be relatively easy. You can then provide this to the `define_app!` macro with the `mutable_store` parameter. Make sure to test this on your local system to ensure that your connections all work as expected before deploying to the server, which you can do with `perseus deploy` and by then copying the `pkg/` directory to the server. + +This approach may seem more resilient and modern, but it comes with a severe downside: speed. Every request that involves mutable data (so any request for a revalidating page or an incrementally generated one) must go through four trips (an extra one to and from the database) rather than two, which is twice as many as usual! This will bring down your site's time to first byte (TTFB) radically, so you should ensure that your mutable store is as close to your server as possible so that the latency between them is negligible. If this performance pitfall is not acceptable, you should use an old-school hosting provider instead. diff --git a/docs/next/src/deploying/serverless.md b/docs/next/src/deploying/serverless.md new file mode 100644 index 0000000000..923dca4156 --- /dev/null +++ b/docs/next/src/deploying/serverless.md @@ -0,0 +1,3 @@ +# Serverless Deployment + +> This strategy of Perseus deployment will be possible eventually, but right now more work needs to be done on support for read-only filesystems before work on this can even be considered. diff --git a/docs/next/src/deploying/size.md b/docs/next/src/deploying/size.md new file mode 100644 index 0000000000..f348ca2809 --- /dev/null +++ b/docs/next/src/deploying/size.md @@ -0,0 +1,43 @@ +# Optimizing Code Size + +If you're used to working with Rust, you're probably used to two things: performance is everything, and Rust produces big binaries. With Wasm, these actually become problems because of the way the web works. If you think about it, your Wasm files (big because Rust optimizes for speed instead of size by default) need to be sent to browsers. So, the larger they are, the slower your site will be. Fortunately, Perseus only makes this relevant when a user first navigates to your site with its [subsequent loads](../advanced/subsequent-loads.md) system. However, it's still worth optimizing code size in places. + +If you've worked with Rust and Wasm before, you may be familiar with `wasm-opt`, which performs a ton of optimizations for you. Perseus does this automatically with `wasm-pack`. But we can do better. + +## `wee_alloc` + +Rust's memory allocator takes up quite a lot of space in your final Wasm binary, and this can be solved by trading off performance for smaller sizes, which can actually make your site snappier because it will load faster. `wee_alloc` is an alternative allocator built for Wasm, and you can enable it by adding it to your `Cargo.toml` as a dependency: + +```toml +wee_alloc = "0.4" +``` + +And then you can add it to the top of your `src/lib.rs`: + +```rust,no_run,no_playground +#[global_allocator] +static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; +``` + +With the [basic example](https://github.com/arctic-hen7/perseus/tree/main/examples/basic), we saw improvements from 369.2kb to 367.8kb with `wee_alloc` and release mode. These aren't much though, and we can do better. + +## Aggressive Optimizations + +More aggressive optimizations need to be applied to both Perseus' engine and your own code, so you'll need to [eject](../ejecting.md) for this to work properly. Just run `perseus eject`, and then add the following to `.perseus/Cargo.toml`: + +```toml +[profile.release] +lto = true +opt-level = "z" +``` + +Then add the same thing to your own `Cargo.toml`. Note that, if this is the only modification you make after ejecting, `perseus deploy` will still work perfectly as expected. + +What this does is enable link-time optimizations, which do magic stuff to make your code smaller, and then we set the compiler to optimize aggressively for speed. On the [basic example](https://github.com/arctic-hen7/perseus/tree/main/examples/basic), we say improvements from 367.8kb with `wee_alloc` and release mode to 295.3kb when we added these extra optimizations. That's very significant, and we recommend using these if you don't have a specific reason not to. Note however that you should definitely test your site's performance after applying these to make sure that you feel you've achieved the right trade-off between performance and speed. If not, you could try setting `opt-level = "s"` instead of `z` to optimize less aggressively for speed, or you could try disabling some optimizations. + +
+Read this if something blows up in your face. + +As of time of writing, Netlify (and possibly other providers) doesn't support Rust binaries that use `lto = true` for some reason, it simply doesn't detect them, so you shouldn't use that particular optimization if you're working with Netlify. + +
diff --git a/docs/next/src/deploying/exporting.md b/docs/next/src/exporting.md similarity index 58% rename from docs/next/src/deploying/exporting.md rename to docs/next/src/exporting.md index 5f6cc4b0a6..8d52c7c7d6 100644 --- a/docs/next/src/deploying/exporting.md +++ b/docs/next/src/exporting.md @@ -1,8 +1,6 @@ # Static Exporting -The easiest way to deploy Perseus is as a set of static files, which is supported if your app uses only the _build state_ and _build paths_ strategies, or none at all. If you use _incremental generation_, _revalidation_, or _request state_ in any of your templates though, you can't export your app to static file, because these strategies require a custom server. For these cases, please continue to the rest of this section to learn how to deploy your more complex setup. - -However, if your app only needs to run server-side computations at build-time, then you can export it to a set of static files without changing anything, simply by running `perseus export`. This will create a new directory called `.perseus/dist/exported`, the contents of which can be served on a system like [GitHub Pages](https:://pages.github.com). Your app should behave in the exact same way with exporting as with normal serving. If this isn't the case, please [open an issue](https://github.com/arctic-hen7/perseus/issues/new/choose). +Thus far, we've used `perseus serve` to build and serve Perseus apps, but there is an alternative way that offers better performance in some cases. Namely, if your app doesn't need any rendering strategies that can't be run at build time (so if you're only using *build state* and/or *build paths* or neither), you can export your app to a set of purely static files that can be served by almost any hosting provider. You can do this by running `perseus export`, which will create a new directory `.perseus/dist/exported/`, the contents of which can be served on a system like [GitHub Pages](https:://pages.github.com). Your app should behave in the exact same way with exporting as with normal serving. If this isn't the case, please [open an issue](https://github.com/arctic-hen7/perseus/issues/new/choose). There is only one known difference between the behavior of your exported site and your normally served site, and that's regarding [static aliases](../static-content.md). In a normal serving scenario, any static aliases that conflicted with a Perseus page or internal asset would be ignored, but, in an exporting context, **any static aliases that conflict with Perseus pages will override them**! If you suspect this might be happening to you, try exporting without those aliases and make sure the URL of your alias file doesn't already exist (in which case it would be a Perseus component). @@ -10,4 +8,4 @@ There is only one known difference between the behavior of your exported site an One slight hiccup with Perseus' static exporting system comes with regards to the `.html` file extension. Perseus' server expects that pages shouldn't have such extensions (hence `/about` rather than `/about.html`), but, when statically generated, they must have these extensions in the filesystem. So, if you don't want these extensions for your users (and if you want consistent behavior between exporting and serving), it's up to whatever system you're hosting your files with to strip these extensions. Many systems do this automatically, though some (like Python's `http.server`) do not. -One of the best systems for testing static exporting on your local machine is the [`serve`](https://github.com/versel/serve) JavaScript package, which can be run from the command-line without touching any JavaScript, and it handles this problem automatically. However, other solutions certainly exist if you don't want any JS polluting your system! +One of the best systems for testing static exporting on your local machine is the [`serve`](https://github.com/vercel/serve) JavaScript package, which can be run from the command-line without touching any JavaScript, and it handles this problem automatically. However, other solutions certainly exist if you don't want any JS polluting your system! diff --git a/docs/next/src/i18n/using.md b/docs/next/src/i18n/using.md index bcdcffba5d..ef830a86ed 100644 --- a/docs/next/src/i18n/using.md +++ b/docs/next/src/i18n/using.md @@ -15,7 +15,7 @@ In that example, we've imported `perseus::t`, and we use it to translate the `he That said, there are some cases in which you'll want access to the underlying `Translator` so you can do more complex things. You can get it like so: ```rust,no_run,no_playground -sycamore::context::use_context::>(); +sycamore::context::use_context::().translator; ``` To see all the methods available on `Translator`, see [the API docs](https://docs.rs/perseus). diff --git a/docs/next/src/second-app.md b/docs/next/src/second-app.md index fb5fd64fa8..2e896dbf75 100644 --- a/docs/next/src/second-app.md +++ b/docs/next/src/second-app.md @@ -112,22 +112,22 @@ This function is what we call in `lib.rs`, and it combines everything else in th Perseus' templating system is extremely versatile, and here we're using it to define our page itself through `.template()`, and to define a function that will modify the document `` (which allows us to add a title) with `.head()`. Notably, we also use the _build state_ rendering strategy, which tells Perseus to call the `get_build_props()` function when your app builds to get some state. More on that now. -### `get_build_props()` - -This function is part of Perseus' secret sauce (actually _open_ sauce), and it will be called when the CLI builds your app to create properties that the template will take (it expects a string, hence the serialization). Here, we just hard-code a greeting in to be used, but the real power of this comes when you start using the fact that this function is `async`. You might query a database to get a list of blog posts, or pull in a Markdown documentation page and parse it, the possibilities are endless! - -Note that this function returns a `RenderFnResultWithCause`, which means that it returns a normal Rust `Result`, where `E` is a `GenericErrorWithCause`, a Perseus type that combines an arbitrary error message with a declaration of who caused the error (either the client or the server). This becomes important when you combine this rendering strategy with others, which are explained in depth later in the book. Note that we use `?` in this example on errors from modules like `serde_json`, showing how versatile this type is. If you don't explicitly construct `GenericErrorWithCause`, blame for the error will be assigned to the server, resulting in a _500 Internal Server Error_ HTTP status code. - -### `template_fn()` +#### `.template()` The result of this function is what Perseus will call when it wants to render your template (which it does more than you might think), and it passes it the props that your template takes as an `Option`. This might seem a bit weird, but there are reasons under the hood. All you need to know here is that if your template takes any properties, they **will** be here, and it's safe to `.unwrap()` them for deserialization. -### `head_fn()` +#### `.head()` This is very similar to `template_fn`, except it can't be reactive. In other words, anything you put in here is like a picture, it can't move (so no buttons, counters, etc.). This is because this modifies the document ``, so you should put metadata, titles, etc. in here. Note that the function we return from here does take an argument (ignored with `_`), that's a string of the properties to your app, but we don't need it in this example. If this page was a generic template for blog posts, you might use this capability to render a different title for each blog post. All this does though is set the ``. If you inspect the source code of the HTML in your browser, you'll find a big comment in the `<head>` that says `<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->`, that separates the stuff that should remain the same on every page from the stuff that should update for each page. +### `get_build_props()` + +This function is part of Perseus' secret sauce (actually _open_ sauce), and it will be called when the CLI builds your app to create properties that the template will take (it expects a string, hence the serialization). Here, we just hard-code a greeting in to be used, but the real power of this comes when you start using the fact that this function is `async`. You might query a database to get a list of blog posts, or pull in a Markdown documentation page and parse it, the possibilities are endless! + +Note that this function returns a `RenderFnResultWithCause<String>`, which means that it returns a normal Rust `Result<String, E>`, where `E` is a `GenericErrorWithCause`, a Perseus type that combines an arbitrary error message with a declaration of who caused the error (either the client or the server). This becomes important when you combine this rendering strategy with others, which are explained in depth later in the book. Note that we use `?` in this example on errors from modules like `serde_json`, showing how versatile this type is. If you don't explicitly construct `GenericErrorWithCause`, blame for the error will be assigned to the server, resulting in a _500 Internal Server Error_ HTTP status code. + ### `set_headers_fn()` This function represents a very powerful feature of Perseus, the ability to set any [HTTP headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers) for a given template. In this case, any time the Perseus server successfully returns our template to the browser, it will call this function on the HTTP response just before it sends it, which will add our custom header `x-test`, setting it to the value `custom value`. diff --git a/docs/next/src/stores.md b/docs/next/src/stores.md new file mode 100644 index 0000000000..a2420de6fa --- /dev/null +++ b/docs/next/src/stores.md @@ -0,0 +1,19 @@ +# Stores + +Perseus has a very unique system of managing data as far as frameworks go, because it sometimes needs to change files it generated at build-time. This would be fine on an old-school server where you control the filesystem, but many modern hosting providers have read-only filesystems, which makes working with Perseus problematic. + +As a solution to this, Perseus divides its data storage into two types: *mutable* (possibly changing at runtime) and *immutable* (never changing after build-time). These correspond to two types in Perseus: `ImmutableStore` (a `struct`) and `MutableStore` (a `trait`). + +## Immutable Stores + +An immutable store is used for all data that won't be changed after it's initially created, like for data about pages that are pre-rendered at build-time that don't revalidate. Because it's read-only after the build process, it can be used on a hosting provider with a read-only filesystem without problems, and so immutable stores always work on the filesystem. The only customizable part of them is the path they write to, which can be set with the `dist_path` parameter in the `define_app!` macro (by default it's `dist/`, relative to `.perseus/`). + +## Mutable Stores + +There are two classes of data that need to be modified at runtime in Perseus: data about pages that can revalidate, and pages cached after incremental generation. There are many ways to deploy a Perseus app, and some involve a read-only filesystem, in which case you'll likely want to use an external database or the like for mutable data. Perseus makes this as easy as possible by making `MutableStore` a `trait` with two simple methods: `read` and `write`. You can see more details in the [API docs](https://docs.rs/perseus). + +By default, Perseus will use `FsMutableStore`, an implementation of `MutableStore` that uses the filesystem at the given path, which is set to `.perseus/dist/mutable/` by default. On hosting providers where you can write to the filesystem and have your changes reliably persist, you can leave this as is. But, if you're using a provider like Netlify, which imposes the restriction of a read-only filesystem, you'll need to implement the `MutableStore` `trait` yourself for a database or the like. + +### Performance of External Mutable Stores + +There are significant downsides to using a non-filesystem mutable store in terms of performance, especially if that store is on another server. Remember that every request to an incrementally-generated page or a page that revalidates will use this external store, which means the request has to travel to the server, to the store, from the store, and from the server, twice as many trips as if the store was on the filesystem. For this reason, Perseus splits data storage into mutable and immutable stores, allowing you to incur these performance costs from the smallest amount of data possible. In previous versions, these stores were combined together, which was problematic for large-scale deployments. diff --git a/docs/next/src/strategies/build-state.md b/docs/next/src/strategies/build-state.md index 3c61e994f9..aaf99cca6d 100644 --- a/docs/next/src/strategies/build-state.md +++ b/docs/next/src/strategies/build-state.md @@ -4,7 +4,7 @@ The most commonly-used rendering strategy for Perseus is static generation, whic Note that, depending on other strategies used, Perseus may call this strategy at build-time or while the server is running, so you shouldn't depend on anything only present in a build environment (particularly if you're using the _incremental generation_ or _revalidation_ strategies). -_Note: Perseus currently still requires a server if you want to export to purely static files, though standalone exports may be added in a future release. In the meantime, check out [Zola](https://getzola.org), which does pure static generation fantastically._ +_Note: if you want to export your app to purely static files, see [this section](../exporting.md), which will help you use Perseus without any server._ ## Usage @@ -29,3 +29,7 @@ You may have noticed in the above example that the build state function takes a When either of these additional strategies are used, _build state_ will be passed the path of the page to be rendered, which allows it to prepare unique properties for that page. In the above example, it just turns the URL into a title and renders that. For further details on _build paths_ and _incremental generation_, see the following sections. + +## Common Pitfalls + +When a user goes to your app from another website, Perseus will send all the data they need down straight away (in the [initial loads](../advanced/initial-loads.md) system), which involves setting any state you provide in a JavaScript global variable so that the browser can access it without needing to talk to the server again (which would slow things down). Unfortunately, JavaScript's concept of 'raw strings' (in which you don't need to escape anything) is quite a bit looser than Rust's, and so Perseus internally escapes any instances of backticks or `${` (JS interpolation syntax). This should all work fine, but, when your state is deserialized, it's not considered acceptable for it to contain *control characters*. In other words, anything like `\n`, `\t` or the like that have special meanings in strings must be escaped before being sent through Perseus! Note that this is a requirement imposed by the lower-level module [`serde_json`](https://github.com/serde-rs/json), not Perseus itself. diff --git a/docs/next/src/styling.md b/docs/next/src/styling.md index 85328f977d..a0c4b2c176 100644 --- a/docs/next/src/styling.md +++ b/docs/next/src/styling.md @@ -1,7 +1,5 @@ # Styling -> Note: Perseus uses [Sycamore](https://github.com/sycamore-rs/sycamore) for writing views, and styling is still [in development](https://sycamore-rs.netlify.app/docs/v0.6/advanced/css) there. - Perseus aims to make styling as easy as possible, though there are a number of things that you should definitely know about before you start to style a Perseus app! It's very easy to import stylesheets with Perseus (be they your own, something like [TailwindCSS](https://tailwindcss.com), etc.). You just add them to the `static/` directory at the root of your project, and then they'll be available at `/.perseus/static/your-filename-here`. That's described in more detail in [this section](./static-content.md). diff --git a/docs/next/src/templates/metadata-modification.md b/docs/next/src/templates/metadata-modification.md index 82608a4f3b..ec127fe6ca 100644 --- a/docs/next/src/templates/metadata-modification.md +++ b/docs/next/src/templates/metadata-modification.md @@ -2,12 +2,12 @@ A big issue with only having one `index.html` file for your whole app is that you don't have the ability to define different `<title>`s and HTML metadata for each page. -However, Perseus overcomes this easily by allowing you to specify `.head()` on a `Template<G>`, which should return a closure that returns a `Template<SsrNode>` (but just write `perseus::template::HeadFn` as the return type, it's an alias for that). The `template!` you define here will be rendered to a `String` and directly interpolated into the `<head>` of any pages this template renders. If you need it to be different based on the properties, you're covered, it takes the same properties as the normal tempalte function! +However, Perseus overcomes this easily by allowing you to specify `.head()` on a `Template<G>`, which should return a closure that returns a `Template<SsrNode>` (but just write `perseus::template::HeadFn` as the return type, it's an alias for that). The `template!` you define here will be rendered to a `String` and directly interpolated into the `<head>` of any pages this template renders. If you need it to be different based on the properties, you're covered, it takes the same properties as the normal template function! The only particular thing to note here is that, because this is rendered to a `String`, this **can't be reactive**. Variable interpolation is fine, but after it's been rendered once, the `<head>` **will not change**. If you need to update it later, you should do that with [`web_sys`](https://docs.rs/web-sys), which lets you directly access any DOM element with similar syntax to JavaScript (in fact, it's your one-stop shop for all things interfacing with the browser, as well as it's companion [`js-sys`](https://docs.rs/js-sys)). Here's an example of modifying a page's metadata (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/basic/src/templates/index.rs)): ```rust,no_run,no_playground -{{#rustdoc_include ../../../../examples/basic/src/templates/index.rs:43:49}} +{{#rustdoc_include ../../../../examples/basic/src/templates/index.rs:32:36}} ``` diff --git a/docs/next/src/views.md b/docs/next/src/views.md index d0638c1782..37afe1d1fc 100644 --- a/docs/next/src/views.md +++ b/docs/next/src/views.md @@ -4,8 +4,8 @@ Perseus is fundamentally a high-level framework around [Sycamore](https://github It would be foolish to reproduce here all the fantastic work of Sycamore, and you can read [their docs](https://sycamore-rs.netlify.app/docs/v0.6/getting_started/installation) to understand how reactivity, variable interpolation, and all the rest of their amazing systems work. -Note that Perseus makes some sections of Sycamore's docs irrelevant (namely the sections on routing and SSR), as they're managed internally. Note that if you want to use Perseus without the CLI (*very* brave), these sections will be extremely relevant: +Note that Perseus makes some sections of Sycamore's docs irrelevant (namely the sections on routing and SSR), as they're managed internally. Note that if you want to use Perseus without the CLI (*very* brave), these sections will be extremely relevant. ## Using Sycamore without Perseus -If you want to create a pure SPA that can be rendered from static files without any server at all, you'll want to use Sycamore without Perseus. Note that this won't provide as good SEO (search engine optimization), and you'll miss out on a number of additional features (like i18n, inferred routing, and rendering strategies), but for applications where these are unnecessary, Sycamore is perfect on its own. +If you want to create a pure SPA without all the overhead of Perseus, you may want to use Sycamore without Perseus. Note that this won't provide as good SEO (search engine optimization), and you'll miss out on a number of additional features (like i18n, inferred routing, rendering strategies, and pre-optimized static exporting without a server), but for applications where these are unnecessary, Sycamore is perfect on its own. diff --git a/examples/basic/.perseus/server/src/main.rs b/examples/basic/.perseus/server/src/main.rs index 9e1f971bdf..64a4d25b30 100644 --- a/examples/basic/.perseus/server/src/main.rs +++ b/examples/basic/.perseus/server/src/main.rs @@ -1,7 +1,7 @@ use actix_web::{App, HttpServer}; use app::{ - get_config_manager, get_error_pages, get_locales, get_static_aliases, get_templates_map, - get_translations_manager, APP_ROOT, + get_error_pages, get_immutable_store, get_locales, get_mutable_store, get_static_aliases, + get_templates_map, get_translations_manager, APP_ROOT, }; use futures::executor::block_on; use perseus_actix_web::{configurer, Options}; @@ -9,21 +9,36 @@ use std::collections::HashMap; use std::env; use std::fs; +// This server executable can be run in two modes: +// dev: inside `.perseus/server/src/main.rs`, works with that file structure +// prod: as a standalone executable with a `dist/` directory as a sibling +// The prod mode can be enabled by setting the `PERSEUS_STANDALONE` environment variable + #[actix_web::main] async fn main() -> std::io::Result<()> { // So we don't have to define a different `FsConfigManager` just for the server, we shift the execution context to the same level as everything else // The server has to be a separate crate because otherwise the dependencies don't work with Wasm bundling - env::set_current_dir("../").unwrap(); + // If we're not running as a standalone binary, assume we're running in dev mode under `.perseus/` + if env::var("PERSEUS_STANDALONE").is_err() { + env::set_current_dir("../").unwrap(); + } + + // This allows us to operate inside `.perseus/` and as a standalone binary in production + let (html_shell_path, static_dir_path) = if env::var("PERSEUS_STANDALONE").is_ok() { + ("./index.html", "./static") + } else { + ("../index.html", "../static") + }; let host = env::var("HOST").unwrap_or_else(|_| "localhost".to_string()); let port = env::var("PORT") .unwrap_or_else(|_| "8080".to_string()) .parse::<u16>(); if let Ok(port) = port { - HttpServer::new(|| { + HttpServer::new(move || { App::new().configure(block_on(configurer( Options { - index: "../index.html".to_string(), // The user must define their own `index.html` file + index: html_shell_path.to_string(), // The user must define their own `index.html` file js_bundle: "dist/pkg/perseus_cli_builder.js".to_string(), // Our crate has the same name, so this will be predictable wasm_bundle: "dist/pkg/perseus_cli_builder_bg.wasm".to_string(), @@ -34,16 +49,17 @@ async fn main() -> std::io::Result<()> { error_pages: get_error_pages(), // The CLI supports static content in `../static` by default if it exists // This will be available directly at `/.perseus/static` - static_dirs: if fs::metadata("../static").is_ok() { + static_dirs: if fs::metadata(static_dir_path).is_ok() { let mut static_dirs = HashMap::new(); - static_dirs.insert("".to_string(), "../static".to_string()); + static_dirs.insert("".to_string(), static_dir_path.to_string()); static_dirs } else { HashMap::new() }, static_aliases: get_static_aliases(), }, - get_config_manager(), + get_immutable_store(), + get_mutable_store(), block_on(get_translations_manager()), ))) }) diff --git a/examples/basic/.perseus/src/bin/build.rs b/examples/basic/.perseus/src/bin/build.rs index 4785df17a2..58a5e92541 100644 --- a/examples/basic/.perseus/src/bin/build.rs +++ b/examples/basic/.perseus/src/bin/build.rs @@ -1,4 +1,7 @@ -use app::{get_config_manager, get_locales, get_templates_vec, get_translations_manager}; +use app::{ + get_immutable_store, get_locales, get_mutable_store, get_templates_vec, + get_translations_manager, +}; use futures::executor::block_on; use perseus::{build_app, SsrNode}; @@ -8,7 +11,8 @@ fn main() { } fn real_main() -> i32 { - let config_manager = get_config_manager(); + let immutable_store = get_immutable_store(); + let mutable_store = get_mutable_store(); let translations_manager = block_on(get_translations_manager()); let locales = get_locales(); @@ -16,7 +20,7 @@ fn real_main() -> i32 { let fut = build_app( get_templates_vec::<SsrNode>(), &locales, - &config_manager, + (&immutable_store, &mutable_store), &translations_manager, // We use another binary to handle exporting false, diff --git a/examples/basic/.perseus/src/bin/export.rs b/examples/basic/.perseus/src/bin/export.rs index 968d6967b1..30bb7b5f84 100644 --- a/examples/basic/.perseus/src/bin/export.rs +++ b/examples/basic/.perseus/src/bin/export.rs @@ -1,6 +1,6 @@ use app::{ - get_config_manager, get_locales, get_static_aliases, get_templates_map, get_templates_vec, - get_translations_manager, APP_ROOT, + get_immutable_store, get_locales, get_mutable_store, get_static_aliases, get_templates_map, + get_templates_vec, get_translations_manager, APP_ROOT, }; use fs_extra::dir::{copy as copy_dir, CopyOptions}; use futures::executor::block_on; @@ -14,7 +14,9 @@ fn main() { } fn real_main() -> i32 { - let config_manager = get_config_manager(); + let immutable_store = get_immutable_store(); + // We don't need this in exporting, but the build process does + let mutable_store = get_mutable_store(); let translations_manager = block_on(get_translations_manager()); let locales = get_locales(); @@ -22,7 +24,7 @@ fn real_main() -> i32 { let build_fut = build_app( get_templates_vec::<SsrNode>(), &locales, - &config_manager, + (&immutable_store, &mutable_store), &translations_manager, // We use another binary to handle normal building true, @@ -37,7 +39,7 @@ fn real_main() -> i32 { "../index.html", &locales, APP_ROOT, - &config_manager, + &immutable_store, &translations_manager, ); if let Err(err) = block_on(export_fut) { diff --git a/examples/basic/.perseus/src/lib.rs b/examples/basic/.perseus/src/lib.rs index f21a1e1de7..52d5401de7 100644 --- a/examples/basic/.perseus/src/lib.rs +++ b/examples/basic/.perseus/src/lib.rs @@ -68,10 +68,11 @@ pub fn run() -> Result<(), JsValue> { RouteVerdict::Found(RouteInfo { path, template, - locale + locale, + was_incremental_match }) => app_shell( path.clone(), - template.clone(), + (template.clone(), *was_incremental_match), locale.clone(), // We give the app shell a translations manager and let it get the `Rc<Translator>` itself (because it can do async safely) Rc::clone(&translations_manager), diff --git a/examples/basic/src/templates/about.rs b/examples/basic/src/templates/about.rs index 51c84749f5..5008b1996d 100644 --- a/examples/basic/src/templates/about.rs +++ b/examples/basic/src/templates/about.rs @@ -11,22 +11,14 @@ pub fn about_page() -> SycamoreTemplate<G> { pub fn get_template<G: GenericNode>() -> Template<G> { Template::new("about") - .template(template_fn()) - .head(head_fn()) -} - -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|_| { - template! { - AboutPage() - } - }) -} - -pub fn head_fn() -> perseus::template::HeadFn { - Rc::new(|_| { - template! { - title { "About Page | Perseus Example – Basic" } - } - }) + .template(Rc::new(|_| { + template! { + AboutPage() + } + })) + .head(Rc::new(|_| { + template! { + title { "About Page | Perseus Example – Basic" } + } + })) } diff --git a/examples/basic/src/templates/index.rs b/examples/basic/src/templates/index.rs index 997d8711f4..1c3714344f 100644 --- a/examples/basic/src/templates/index.rs +++ b/examples/basic/src/templates/index.rs @@ -22,9 +22,19 @@ pub fn index_page(props: IndexPageProps) -> SycamoreTemplate<G> { pub fn get_template<G: GenericNode>() -> Template<G> { Template::new("index") .build_state_fn(Rc::new(get_build_props)) - .template(template_fn()) - .head(head_fn()) - .set_headers_fn(set_headers_fn()) + .template(Rc::new(|props: Option<String>| { + template! { + IndexPage( + serde_json::from_str::<IndexPageProps>(&props.unwrap()).unwrap() + ) + } + })) + .head(Rc::new(|_| { + template! { + title { "Index Page | Perseus Example – Basic" } + } + })) + .set_headers_fn(Rc::new(set_headers)) } pub async fn get_build_props(_path: String) -> RenderFnResultWithCause<String> { @@ -33,31 +43,11 @@ pub async fn get_build_props(_path: String) -> RenderFnResultWithCause<String> { })?) // This `?` declares the default, that the server is the cause of the error } -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|props: Option<String>| { - template! { - IndexPage( - serde_json::from_str::<IndexPageProps>(&props.unwrap()).unwrap() - ) - } - }) -} - -pub fn head_fn() -> perseus::template::HeadFn { - Rc::new(|_| { - template! { - title { "Index Page | Perseus Example – Basic" } - } - }) -} - -pub fn set_headers_fn() -> perseus::template::SetHeadersFn { - Rc::new(|_| { - let mut map = HeaderMap::new(); - map.insert( - HeaderName::from_lowercase(b"x-test").unwrap(), - "custom value".parse().unwrap(), - ); - map - }) +pub fn set_headers(_props: Option<String>) -> HeaderMap { + let mut map = HeaderMap::new(); + map.insert( + HeaderName::from_lowercase(b"x-test").unwrap(), + "custom value".parse().unwrap(), + ); + map } diff --git a/examples/i18n/src/templates/about.rs b/examples/i18n/src/templates/about.rs index d86a1037ee..ba8ae78b72 100644 --- a/examples/i18n/src/templates/about.rs +++ b/examples/i18n/src/templates/about.rs @@ -18,14 +18,10 @@ pub fn about_page() -> SycamoreTemplate<G> { } } -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|_| { +pub fn get_template<G: GenericNode>() -> Template<G> { + Template::new("about").template(Rc::new(|_| { template! { AboutPage() } - }) -} - -pub fn get_template<G: GenericNode>() -> Template<G> { - Template::new("about").template(template_fn()) + })) } diff --git a/examples/i18n/src/templates/index.rs b/examples/i18n/src/templates/index.rs index 47ac5e9000..793dddfc78 100644 --- a/examples/i18n/src/templates/index.rs +++ b/examples/i18n/src/templates/index.rs @@ -13,14 +13,10 @@ pub fn index_page() -> SycamoreTemplate<G> { } } -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|_| { +pub fn get_template<G: GenericNode>() -> Template<G> { + Template::new("index").template(Rc::new(|_| { template! { IndexPage() } - }) -} - -pub fn get_template<G: GenericNode>() -> Template<G> { - Template::new("index").template(template_fn()) + })) } diff --git a/examples/i18n/src/templates/post.rs b/examples/i18n/src/templates/post.rs index 2561475194..2695e6da6e 100644 --- a/examples/i18n/src/templates/post.rs +++ b/examples/i18n/src/templates/post.rs @@ -27,7 +27,13 @@ pub fn get_template<G: GenericNode>() -> Template<G> { Template::new("post") .build_paths_fn(Rc::new(get_static_paths)) .build_state_fn(Rc::new(get_static_props)) - .template(template_fn()) + .template(Rc::new(|props| { + template! { + PostPage( + serde_json::from_str::<PostPageProps>(&props.unwrap()).unwrap() + ) + } + })) } pub async fn get_static_props(path: String) -> RenderFnResultWithCause<String> { @@ -47,13 +53,3 @@ pub async fn get_static_props(path: String) -> RenderFnResultWithCause<String> { pub async fn get_static_paths() -> RenderFnResult<Vec<String>> { Ok(vec!["test".to_string(), "blah/test/blah".to_string()]) } - -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|props| { - template! { - PostPage( - serde_json::from_str::<PostPageProps>(&props.unwrap()).unwrap() - ) - } - }) -} diff --git a/examples/showcase/src/templates/about.rs b/examples/showcase/src/templates/about.rs index 0ff79d7bd6..9ef3c905dc 100644 --- a/examples/showcase/src/templates/about.rs +++ b/examples/showcase/src/templates/about.rs @@ -10,13 +10,9 @@ pub fn about_page() -> SycamoreTemplate<G> { } pub fn get_template<G: GenericNode>() -> Template<G> { - Template::new("about").template(template_fn()) -} - -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|_| { + Template::new("about").template(Rc::new(|_| { template! { AboutPage() } - }) + })) } diff --git a/examples/showcase/src/templates/amalgamation.rs b/examples/showcase/src/templates/amalgamation.rs index 60e92d0404..cec24f3063 100644 --- a/examples/showcase/src/templates/amalgamation.rs +++ b/examples/showcase/src/templates/amalgamation.rs @@ -8,8 +8,8 @@ pub struct AmalagamationPageProps { pub message: String, } -#[component(AboutPage<G>)] -pub fn about_page(props: AmalagamationPageProps) -> SycamoreTemplate<G> { +#[component(AmalgamationPage<G>)] +pub fn amalgamation_page(props: AmalagamationPageProps) -> SycamoreTemplate<G> { template! { p { (format!("The message is: '{}'", props.message)) } } @@ -20,7 +20,13 @@ pub fn get_template<G: GenericNode>() -> Template<G> { .build_state_fn(Rc::new(get_build_state)) .request_state_fn(Rc::new(get_request_state)) .amalgamate_states_fn(Rc::new(amalgamate_states)) - .template(template_fn()) + .template(Rc::new(|props| { + template! { + AmalgamationPage( + serde_json::from_str::<AmalagamationPageProps>(&props.unwrap()).unwrap() + ) + } + })) } pub fn amalgamate_states(states: States) -> RenderFnResultWithCause<Option<String>> { @@ -51,13 +57,3 @@ pub async fn get_request_state(_path: String, _req: Request) -> RenderFnResultWi message: "Hello from the server!".to_string(), })?) } - -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|props| { - template! { - AboutPage( - serde_json::from_str::<AmalagamationPageProps>(&props.unwrap()).unwrap() - ) - } - }) -} diff --git a/examples/showcase/src/templates/index.rs b/examples/showcase/src/templates/index.rs index 53b8f40e15..016235db03 100644 --- a/examples/showcase/src/templates/index.rs +++ b/examples/showcase/src/templates/index.rs @@ -18,7 +18,13 @@ pub fn index_page(props: IndexPageProps) -> SycamoreTemplate<G> { pub fn get_template<G: GenericNode>() -> Template<G> { Template::new("index") .build_state_fn(Rc::new(get_static_props)) - .template(template_fn()) + .template(Rc::new(|props| { + template! { + IndexPage( + serde_json::from_str::<IndexPageProps>(&props.unwrap()).unwrap() + ) + } + })) } pub async fn get_static_props(_path: String) -> RenderFnResultWithCause<String> { @@ -26,13 +32,3 @@ pub async fn get_static_props(_path: String) -> RenderFnResultWithCause<String> greeting: "Hello World!".to_string(), })?) } - -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|props| { - template! { - IndexPage( - serde_json::from_str::<IndexPageProps>(&props.unwrap()).unwrap() - ) - } - }) -} diff --git a/examples/showcase/src/templates/ip.rs b/examples/showcase/src/templates/ip.rs index 0457473788..dad3dc7358 100644 --- a/examples/showcase/src/templates/ip.rs +++ b/examples/showcase/src/templates/ip.rs @@ -22,7 +22,13 @@ pub fn dashboard_page(props: IpPageProps) -> SycamoreTemplate<G> { pub fn get_template<G: GenericNode>() -> Template<G> { Template::new("ip") .request_state_fn(Rc::new(get_request_state)) - .template(template_fn()) + .template(Rc::new(|props| { + template! { + IpPage( + serde_json::from_str::<IpPageProps>(&props.unwrap()).unwrap() + ) + } + })) } pub async fn get_request_state(_path: String, req: Request) -> RenderFnResultWithCause<String> { @@ -40,13 +46,3 @@ pub async fn get_request_state(_path: String, req: Request) -> RenderFnResultWit ), })?) } - -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|props| { - template! { - IpPage( - serde_json::from_str::<IpPageProps>(&props.unwrap()).unwrap() - ) - } - }) -} diff --git a/examples/showcase/src/templates/new_post.rs b/examples/showcase/src/templates/new_post.rs index 73a59ee4f8..1daf110f5b 100644 --- a/examples/showcase/src/templates/new_post.rs +++ b/examples/showcase/src/templates/new_post.rs @@ -10,13 +10,9 @@ pub fn new_post_page() -> SycamoreTemplate<G> { } pub fn get_template<G: GenericNode>() -> Template<G> { - Template::new("post/new").template(template_fn()) -} - -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|_| { + Template::new("post/new").template(Rc::new(|_| { template! { NewPostPage() } - }) + })) } diff --git a/examples/showcase/src/templates/post.rs b/examples/showcase/src/templates/post.rs index a0bcd7900d..02907b1321 100644 --- a/examples/showcase/src/templates/post.rs +++ b/examples/showcase/src/templates/post.rs @@ -30,7 +30,13 @@ pub fn get_template<G: GenericNode>() -> Template<G> { .build_paths_fn(Rc::new(get_static_paths)) .build_state_fn(Rc::new(get_static_props)) .incremental_generation() - .template(template_fn()) + .template(Rc::new(|props| { + template! { + PostPage( + serde_json::from_str::<PostPageProps>(&props.unwrap()).unwrap() + ) + } + })) } pub async fn get_static_props(path: String) -> RenderFnResultWithCause<String> { @@ -57,13 +63,3 @@ pub async fn get_static_props(path: String) -> RenderFnResultWithCause<String> { pub async fn get_static_paths() -> RenderFnResult<Vec<String>> { Ok(vec!["test".to_string(), "blah/test/blah".to_string()]) } - -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|props| { - template! { - PostPage( - serde_json::from_str::<PostPageProps>(&props.unwrap()).unwrap() - ) - } - }) -} diff --git a/examples/showcase/src/templates/time.rs b/examples/showcase/src/templates/time.rs index a348b31ba3..4ae2e818c2 100644 --- a/examples/showcase/src/templates/time.rs +++ b/examples/showcase/src/templates/time.rs @@ -19,7 +19,13 @@ pub fn time_page(props: TimePageProps) -> SycamoreTemplate<G> { pub fn get_template<G: GenericNode>() -> Template<G> { Template::new("timeisr") - .template(template_fn()) + .template(Rc::new(|props| { + template! { + TimePage( + serde_json::from_str::<TimePageProps>(&props.unwrap()).unwrap() + ) + } + })) // This page will revalidate every five seconds (to illustrate revalidation) .revalidate_after("5s".to_string()) .incremental_generation() @@ -43,13 +49,3 @@ pub async fn get_build_state(path: String) -> RenderFnResultWithCause<String> { pub async fn get_build_paths() -> RenderFnResult<Vec<String>> { Ok(vec!["test".to_string()]) } - -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|props| { - template! { - TimePage( - serde_json::from_str::<TimePageProps>(&props.unwrap()).unwrap() - ) - } - }) -} diff --git a/examples/showcase/src/templates/time_root.rs b/examples/showcase/src/templates/time_root.rs index 2cffea0d98..84334e1855 100644 --- a/examples/showcase/src/templates/time_root.rs +++ b/examples/showcase/src/templates/time_root.rs @@ -17,7 +17,13 @@ pub fn time_page(props: TimePageProps) -> SycamoreTemplate<G> { pub fn get_template<G: GenericNode>() -> Template<G> { Template::new("time") - .template(template_fn()) + .template(Rc::new(|props| { + template! { + TimePage( + serde_json::from_str::<TimePageProps>(&props.unwrap()).unwrap() + ) + } + })) // This page will revalidate every five seconds (to illustrate revalidation) // Try changing this to a week, even though the below custom logic says to always revalidate, we'll only do it weekly .revalidate_after("5s".to_string()) @@ -30,13 +36,3 @@ pub async fn get_build_state(_path: String) -> RenderFnResultWithCause<String> { time: format!("{:?}", std::time::SystemTime::now()), })?) } - -pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> { - Rc::new(|props| { - template! { - TimePage( - serde_json::from_str::<TimePageProps>(&props.unwrap()).unwrap() - ) - } - }) -} diff --git a/packages/perseus-actix-web/src/configurer.rs b/packages/perseus-actix-web/src/configurer.rs index b2819e905f..4d311ebd3e 100644 --- a/packages/perseus-actix-web/src/configurer.rs +++ b/packages/perseus-actix-web/src/configurer.rs @@ -4,8 +4,10 @@ use crate::translations::translations; use actix_files::{Files, NamedFile}; use actix_web::{web, HttpRequest}; use perseus::{ - get_render_cfg, html_shell::prep_html_shell, ConfigManager, ErrorPages, Locales, SsrNode, - TemplateMap, TranslationsManager, + get_render_cfg, + html_shell::prep_html_shell, + stores::{ImmutableStore, MutableStore}, + ErrorPages, Locales, SsrNode, TemplateMap, TranslationsManager, }; use std::collections::HashMap; use std::fs; @@ -58,12 +60,13 @@ async fn static_alias(opts: web::Data<Options>, req: HttpRequest) -> std::io::Re /// Configures an existing Actix Web app for Perseus. This returns a function that does the configuring so it can take arguments. This /// includes a complete wildcard handler (`*`), and so it should be configured after any other routes on your server. -pub async fn configurer<C: ConfigManager + 'static, T: TranslationsManager + 'static>( +pub async fn configurer<M: MutableStore + 'static, T: TranslationsManager + 'static>( opts: Options, - config_manager: C, + immutable_store: ImmutableStore, + mutable_store: M, translations_manager: T, ) -> impl Fn(&mut web::ServiceConfig) { - let render_cfg = get_render_cfg(&config_manager) + let render_cfg = get_render_cfg(&immutable_store) .await .expect("Couldn't get render configuration!"); // Get the index file and inject the render configuration into ahead of time @@ -75,7 +78,8 @@ pub async fn configurer<C: ConfigManager + 'static, T: TranslationsManager + 'st cfg // We implant the render config in the app data for better performance, it's needed on every request .data(render_cfg.clone()) - .data(config_manager.clone()) + .data(immutable_store.clone()) + .data(mutable_store.clone()) .data(translations_manager.clone()) .data(opts.clone()) .data(index_with_render_cfg.clone()) @@ -89,7 +93,7 @@ pub async fn configurer<C: ConfigManager + 'static, T: TranslationsManager + 'st // 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::<C, T>), + web::get().to(page_data::<M, T>), ) // This allows the app shell to fetch translations for a given page .route( @@ -110,6 +114,6 @@ pub async fn configurer<C: ConfigManager + 'static, T: TranslationsManager + 'st } // 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("*", web::get().to(initial_load::<C, T>)); + cfg.route("*", web::get().to(initial_load::<M, T>)); } } diff --git a/packages/perseus-actix-web/src/initial_load.rs b/packages/perseus-actix-web/src/initial_load.rs index da5be3a2d1..eaca524c5b 100644 --- a/packages/perseus-actix-web/src/initial_load.rs +++ b/packages/perseus-actix-web/src/initial_load.rs @@ -6,8 +6,10 @@ use perseus::error_pages::ErrorPageData; use perseus::html_shell::interpolate_page_data; use perseus::router::{match_route, RouteInfo, RouteVerdict}; use perseus::{ - err_to_status_code, serve::get_page_for_template, ConfigManager, ErrorPages, SsrNode, - TranslationsManager, Translator, + err_to_status_code, + serve::get_page_for_template, + stores::{ImmutableStore, MutableStore}, + ErrorPages, SsrNode, TranslationsManager, Translator, }; use std::collections::HashMap; use std::rc::Rc; @@ -33,7 +35,6 @@ fn return_error_page( }) .unwrap(); // Add a global variable that defines this as an error - // TODO fix lack of support for `\"` here (causes an error) let state_var = format!( "<script>window.__PERSEUS_INITIAL_STATE = `error-{}`;</script>", error_page_data @@ -66,12 +67,13 @@ fn return_error_page( /// The handler for calls to any actual pages (first-time visits), which will render the appropriate HTML and then interpolate it into /// the app shell. -pub async fn initial_load<C: ConfigManager, T: TranslationsManager>( +pub async fn initial_load<M: MutableStore, T: TranslationsManager>( req: HttpRequest, opts: web::Data<Options>, html_shell: web::Data<String>, render_cfg: web::Data<HashMap<String, String>>, - config_manager: web::Data<C>, + immutable_store: web::Data<ImmutableStore>, + mutable_store: web::Data<M>, translations_manager: web::Data<T>, ) -> HttpResponse { let templates = &opts.templates_map; @@ -104,6 +106,7 @@ pub async fn initial_load<C: ConfigManager, T: TranslationsManager>( 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); @@ -119,8 +122,9 @@ pub async fn initial_load<C: ConfigManager, T: TranslationsManager>( &path, &locale, &template, + was_incremental_match, http_req, - config_manager.get_ref(), + (immutable_store.get_ref(), mutable_store.get_ref()), translations_manager.get_ref(), ) .await; diff --git a/packages/perseus-actix-web/src/page_data.rs b/packages/perseus-actix-web/src/page_data.rs index a94d7e1540..a79db2a512 100644 --- a/packages/perseus-actix-web/src/page_data.rs +++ b/packages/perseus-actix-web/src/page_data.rs @@ -3,26 +3,34 @@ use crate::Options; use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse}; use fmterr::fmt_err; use perseus::{ - err_to_status_code, serve::get_page_for_template, ConfigManager, TranslationsManager, + err_to_status_code, + serve::get_page_for_template, + stores::{ImmutableStore, MutableStore}, + TranslationsManager, }; 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. -pub async fn page_data<C: ConfigManager, T: TranslationsManager>( +pub async fn page_data<M: MutableStore, T: TranslationsManager>( req: HttpRequest, opts: web::Data<Options>, - config_manager: web::Data<C>, + immutable_store: web::Data<ImmutableStore>, + mutable_store: web::Data<M>, translations_manager: web::Data<T>, web::Query(query_params): web::Query<PageDataReq>, ) -> HttpResponse { let templates = &opts.templates_map; let locale = req.match_info().query("locale"); - let template_name = query_params.template_name; + 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"); @@ -48,8 +56,9 @@ pub async fn page_data<C: ConfigManager, T: TranslationsManager>( path, locale, template, + was_incremental_match, http_req, - config_manager.get_ref(), + (immutable_store.get_ref(), mutable_store.get_ref()), translations_manager.get_ref(), ) .await; diff --git a/packages/perseus-cli/Cargo.toml b/packages/perseus-cli/Cargo.toml index aa4c99551c..e0ece03c6f 100644 --- a/packages/perseus-cli/Cargo.toml +++ b/packages/perseus-cli/Cargo.toml @@ -28,6 +28,7 @@ console = "0.14" serde = "1" serde_json = "1" clap = "3.0.0-beta.4" +fs_extra = "1" [lib] name = "perseus_cli" diff --git a/packages/perseus-cli/src/bin/main.rs b/packages/perseus-cli/src/bin/main.rs index 1c43bc965b..d193365af0 100644 --- a/packages/perseus-cli/src/bin/main.rs +++ b/packages/perseus-cli/src/bin/main.rs @@ -2,7 +2,7 @@ use clap::Clap; use fmterr::fmt_err; use perseus_cli::errors::*; use perseus_cli::{ - build, check_env, delete_artifacts, delete_bad_dir, eject, export, has_ejected, + build, check_env, delete_artifacts, delete_bad_dir, deploy, eject, export, has_ejected, parse::{Opts, Subcommand}, prepare, serve, }; @@ -94,7 +94,8 @@ fn core(dir: PathBuf) -> Result<i32, Error> { if !serve_opts.no_build { delete_artifacts(dir.clone(), "static")?; } - serve(dir, serve_opts)? + let (exit_code, _server_path) = serve(dir, serve_opts)?; + exit_code } Subcommand::Test(test_opts) => { // This will be used by the subcrates @@ -105,7 +106,8 @@ fn core(dir: PathBuf) -> Result<i32, Error> { if !test_opts.no_build { delete_artifacts(dir.clone(), "static")?; } - serve(dir, test_opts)? + let (exit_code, _server_path) = serve(dir, test_opts)?; + exit_code } Subcommand::Clean(clean_opts) => { if clean_opts.dist { @@ -124,6 +126,12 @@ fn core(dir: PathBuf) -> Result<i32, Error> { } 0 } + Subcommand::Deploy(deploy_opts) => { + delete_artifacts(dir.clone(), "static")?; + delete_artifacts(dir.clone(), "exported")?; + delete_artifacts(dir.clone(), "pkg")?; + deploy(dir, deploy_opts)? + } Subcommand::Eject => { eject(dir)?; 0 diff --git a/packages/perseus-cli/src/deploy.rs b/packages/perseus-cli/src/deploy.rs new file mode 100644 index 0000000000..70b8c9ae0e --- /dev/null +++ b/packages/perseus-cli/src/deploy.rs @@ -0,0 +1,196 @@ +use crate::errors::*; +use crate::export; +use crate::parse::{DeployOpts, ExportOpts, ServeOpts}; +use crate::serve; +use fs_extra::copy_items; +use fs_extra::dir::{copy as copy_dir, CopyOptions}; +use std::fs; +use std::path::PathBuf; + +/// Deploys the user's app to the `pkg/` directory (can be changed with `-o/--output`). This will build everything for release and then +/// put it all together in one folder that can be conveniently uploaded to a server, file host, etc. This can return any kind of error +/// because deploying involves working with other subcommands. +pub fn deploy(dir: PathBuf, opts: DeployOpts) -> Result<i32, Error> { + // Fork at whether we're using static exporting or not + let exit_code = if opts.export_static { + deploy_export(dir, opts.output)? + } else { + deploy_full(dir, opts.output)? + }; + + Ok(exit_code) +} + +/// Deploys the user's app in its entirety, with a bundled server. This can return any kind of error because deploying involves working +/// with other subcommands. +fn deploy_full(dir: PathBuf, output: String) -> Result<i32, Error> { + // Build everything for production, not running the server + let (serve_exit_code, server_path) = serve( + dir.clone(), + ServeOpts { + no_run: true, + no_build: false, + release: true, + }, + )?; + if serve_exit_code != 0 { + return Ok(serve_exit_code); + } + if let Some(server_path) = server_path { + // Delete the output directory if it exists and recreate it + let output_path = PathBuf::from(&output); + if output_path.exists() { + if let Err(err) = fs::remove_dir_all(&output_path) { + return Err(DeployError::ReplaceOutputDirFailed { + path: output, + source: err, + } + .into()); + } + } + if let Err(err) = fs::create_dir(&output_path) { + return Err(DeployError::ReplaceOutputDirFailed { + path: output, + source: err, + } + .into()); + } + // Copy in the server executable + let to = output_path.join("server"); + if let Err(err) = fs::copy(&server_path, &to) { + return Err(DeployError::MoveAssetFailed { + to: to.to_str().map(|s| s.to_string()).unwrap(), + from: server_path, + source: err, + } + .into()); + } + // Copy in the `index.html` file + let from = dir.join("index.html"); + let to = output_path.join("index.html"); + if let Err(err) = fs::copy(&from, &to) { + return Err(DeployError::MoveAssetFailed { + to: to.to_str().map(|s| s.to_string()).unwrap(), + from: from.to_str().map(|s| s.to_string()).unwrap(), + source: err, + } + .into()); + } + // Copy in the `static/` directory if it exists + let from = dir.join("static"); + if from.exists() { + if let Err(err) = copy_dir(&from, &output, &CopyOptions::new()) { + return Err(DeployError::MoveDirFailed { + to: output, + from: from.to_str().map(|s| s.to_string()).unwrap(), + source: err, + } + .into()); + } + } + // Copy in the `translations` directory if it exists + let from = dir.join("translations"); + if from.exists() { + if let Err(err) = copy_dir(&from, &output, &CopyOptions::new()) { + return Err(DeployError::MoveDirFailed { + to: output, + from: from.to_str().map(|s| s.to_string()).unwrap(), + source: err, + } + .into()); + } + } + // Copy in the entire `.perseus/dist` directory (it must exist) + let from = dir.join(".perseus/dist"); + if let Err(err) = copy_dir(&from, &output, &CopyOptions::new()) { + return Err(DeployError::MoveDirFailed { + to: output, + from: from.to_str().map(|s| s.to_string()).unwrap(), + source: err, + } + .into()); + } + + println!(); + println!("Deployment complete 🚀! Your app is now available for serving in the standalone folder '{}'! You can run it by executing the `server` binary in that folder with the `PERSEUS_STANDALONE` environment variable set to `true`.", &output_path.to_str().map(|s| s.to_string()).unwrap()); + + Ok(0) + } else { + // If we don't have the executable, throw an error + Err(DeployError::GetServerExecutableFailed.into()) + } +} + +/// Uses static exporting to deploy the user's app. This can return any kind of error because deploying involves working with other +/// subcommands. +fn deploy_export(dir: PathBuf, output: String) -> Result<i32, Error> { + // Export the app to `.perseus/exported`, using release mode + let export_exit_code = export(dir.clone(), ExportOpts { release: true })?; + if export_exit_code != 0 { + return Ok(export_exit_code); + } + // That subcommand produces a self-contained static site at `.perseus/dist/exported/` + // Just copy that out to the output directory + let from = dir.join(".perseus/dist/exported"); + let output_path = PathBuf::from(&output); + // Delete the output directory if it exists and recreate it + if output_path.exists() { + if let Err(err) = fs::remove_dir_all(&output_path) { + return Err(DeployError::ReplaceOutputDirFailed { + path: output, + source: err, + } + .into()); + } + } + if let Err(err) = fs::create_dir(&output_path) { + return Err(DeployError::ReplaceOutputDirFailed { + path: output, + source: err, + } + .into()); + } + // Now read the contents of the export directory so that we can copy each asset in individually + // That avoids a `pkg/exported/` situation + let items = fs::read_dir(&from); + let items: Vec<PathBuf> = match items { + Ok(items) => { + let mut ok_items = Vec::new(); + for item in items { + match item { + Ok(item) => ok_items.push(item.path()), + Err(err) => { + return Err(DeployError::ReadExportDirFailed { + path: from.to_str().map(|s| s.to_string()).unwrap(), + source: err, + } + .into()) + } + } + } + + ok_items + } + Err(err) => { + return Err(DeployError::ReadExportDirFailed { + path: from.to_str().map(|s| s.to_string()).unwrap(), + source: err, + } + .into()) + } + }; + // Now run the copy of each item + if let Err(err) = copy_items(&items, &output, &CopyOptions::new()) { + return Err(DeployError::MoveExportDirFailed { + to: output, + from: from.to_str().map(|s| s.to_string()).unwrap(), + source: err, + } + .into()); + } + + println!(); + println!("Deployment complete 🚀! Your app is now available for serving in the standalone folder '{}'! You can run it by serving the contents of that folder however you'd like.", &output_path.to_str().map(|s| s.to_string()).unwrap()); + + Ok(0) +} diff --git a/packages/perseus-cli/src/errors.rs b/packages/perseus-cli/src/errors.rs index 6dfd58e30b..afff0e073b 100644 --- a/packages/perseus-cli/src/errors.rs +++ b/packages/perseus-cli/src/errors.rs @@ -13,6 +13,8 @@ pub enum Error { EjectionError(#[from] EjectionError), #[error(transparent)] ExportError(#[from] ExportError), + #[error(transparent)] + DeployError(#[from] DeployError), } /// Errors that can occur while preparing. @@ -142,7 +144,7 @@ pub enum ExportError { #[source] source: std::io::Error, }, - #[error("couldn't copy asset from '{to}' to '{from}' for exporting")] + #[error("couldn't copy asset from '{from}' to '{to}' for exporting")] MoveAssetFailed { to: String, from: String, @@ -153,3 +155,43 @@ pub enum ExportError { #[error(transparent)] ExecutionError(#[from] ExecutionError), } + +/// Errors that can occur while running `perseus deploy`. +#[derive(Error, Debug)] +pub enum DeployError { + #[error("couldn't copy exported static files from '{from:?}' to '{to}'")] + MoveExportDirFailed { + to: String, + from: String, + #[source] + source: fs_extra::error::Error, + }, + #[error("couldn't delete and recreate output directory '{path}'")] + ReplaceOutputDirFailed { + path: String, + #[source] + source: std::io::Error, + }, + #[error("couldn't get path to server executable (if this persists, try `perseus clean`)")] + GetServerExecutableFailed, + #[error("couldn't copy file from '{from}' to '{to}' for deployment packaging")] + MoveAssetFailed { + to: String, + from: String, + #[source] + source: std::io::Error, + }, + #[error("couldn't copy directory from '{from}' to '{to}' for deployment packaging")] + MoveDirFailed { + to: String, + from: String, + #[source] + source: fs_extra::error::Error, + }, + #[error("couldn't read contents of export directory '{path}' for packaging (if this persists, try `perseus clean`)")] + ReadExportDirFailed { + path: String, + #[source] + source: std::io::Error, + }, +} diff --git a/packages/perseus-cli/src/lib.rs b/packages/perseus-cli/src/lib.rs index d02652a245..22318eea25 100644 --- a/packages/perseus-cli/src/lib.rs +++ b/packages/perseus-cli/src/lib.rs @@ -38,6 +38,7 @@ mod prepare; mod serve; mod thread; +mod deploy; mod extraction; use errors::*; @@ -47,6 +48,7 @@ use std::path::PathBuf; /// The current version of the CLI, extracted from the crate version. pub const PERSEUS_VERSION: &str = env!("CARGO_PKG_VERSION"); pub use build::build; +pub use deploy::deploy; pub use eject::{eject, has_ejected}; pub use export::export; pub use prepare::{check_env, prepare}; diff --git a/packages/perseus-cli/src/parse.rs b/packages/perseus-cli/src/parse.rs index d1339715a3..ea9ee9ca12 100644 --- a/packages/perseus-cli/src/parse.rs +++ b/packages/perseus-cli/src/parse.rs @@ -24,6 +24,7 @@ pub enum Subcommand { Clean(CleanOpts), /// Ejects you from the CLI harness, enabling you to work with the internals of Perseus Eject, + Deploy(DeployOpts), /// Prepares the `.perseus/` directory (done automatically by `build` and `serve`) Prep, } @@ -64,3 +65,13 @@ pub struct CleanOpts { #[clap(short, long)] pub force: bool, } +/// Packages your app for deployment +#[derive(Clap)] +pub struct DeployOpts { + /// Change the output from `pkg/` to somewhere else + #[clap(short, long, default_value = "pkg")] + pub output: String, + /// Export you app to purely static files (see `export`) + #[clap(short, long)] + pub export_static: bool, +} diff --git a/packages/perseus-cli/src/serve.rs b/packages/perseus-cli/src/serve.rs index 6436c5b9d1..f85dad203a 100644 --- a/packages/perseus-cli/src/serve.rs +++ b/packages/perseus-cli/src/serve.rs @@ -175,10 +175,10 @@ fn run_server( Ok(0) } -/// Builds the subcrates to get a directory that we can serve and then serves it. -pub fn serve(dir: PathBuf, opts: ServeOpts) -> Result<i32, ExecutionError> { +/// Builds the subcrates to get a directory that we can serve and then serves it. If possible, this will return the path to the server +/// executable so that it can be used in deployment. +pub fn serve(dir: PathBuf, opts: ServeOpts) -> Result<(i32, Option<String>), ExecutionError> { let spinners = MultiProgress::new(); - // TODO support watching files let did_build = !opts.no_build; let should_run = !opts.no_run; // We need to have a way of knowing what the executable path to the server is @@ -201,9 +201,9 @@ pub fn serve(dir: PathBuf, opts: ServeOpts) -> Result<i32, ExecutionError> { .join() .map_err(|_| ExecutionError::ThreadWaitFailed)??; if sg_res != 0 { - return Ok(sg_res); + return Ok((sg_res, None)); } else if wb_res != 0 { - return Ok(wb_res); + return Ok((wb_res, None)); } } // Handle errors from the server building @@ -211,7 +211,7 @@ pub fn serve(dir: PathBuf, opts: ServeOpts) -> Result<i32, ExecutionError> { .join() .map_err(|_| ExecutionError::ThreadWaitFailed)??; if sb_res != 0 { - return Ok(sb_res); + return Ok((sb_res, None)); } // And now we can run the finalization stage (only if `--no-build` wasn't specified) @@ -222,11 +222,11 @@ pub fn serve(dir: PathBuf, opts: ServeOpts) -> Result<i32, ExecutionError> { // Now actually run that executable path if we should if should_run { let exit_code = run_server(Arc::clone(&exec), dir, did_build)?; - Ok(exit_code) + Ok((exit_code, None)) } else { // The user doesn't want to run the server, so we'll give them the executable path instead let exec_str: String = (*exec.lock().unwrap()).to_string(); - println!("Not running server because `--no-run` was provided. You can run it manually by running the following executable in `.perseus/server/`.\n{}", exec_str); - Ok(0) + println!("Not running server because `--no-run` was provided. You can run it manually by running the following executable in `.perseus/server/`.\n{}", &exec_str); + Ok((0, Some(exec_str))) } } diff --git a/packages/perseus/src/build.rs b/packages/perseus/src/build.rs index e20589375e..93f2c528d7 100644 --- a/packages/perseus/src/build.rs +++ b/packages/perseus/src/build.rs @@ -4,7 +4,11 @@ use crate::errors::*; use crate::Locales; use crate::TranslationsManager; use crate::Translator; -use crate::{config_manager::ConfigManager, decode_time_str::decode_time_str, template::Template}; +use crate::{ + decode_time_str::decode_time_str, + stores::{ImmutableStore, MutableStore}, + template::Template, +}; use futures::future::try_join_all; use std::collections::HashMap; use std::rc::Rc; @@ -17,7 +21,7 @@ use sycamore::prelude::SsrNode; pub async fn build_template( template: &Template<SsrNode>, translator: Rc<Translator>, - config_manager: &impl ConfigManager, + (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), exporting: bool, ) -> Result<(Vec<String>, bool), ServerError> { let mut single_page = false; @@ -48,24 +52,55 @@ pub async fn build_template( }; // Iterate through the paths to generate initial states if needed + // Note that build paths pages on incrementally generable pages will use the immutable store for path in paths.iter() { // If needed, we'll contruct a full path that's URL encoded so we can easily save it as a file // BUG: insanely nested paths won't work whatsoever if the filename is too long, maybe hash instead? - let full_path = match template.uses_build_paths() { + let full_path_without_locale = match template.uses_build_paths() { true => urlencoding::encode(&format!("{}/{}", &template_path, path)).to_string(), // We don't want to concatenate the name twice if we don't have to false => urlencoding::encode(&template_path).to_string(), }; // Add the current locale to the front of that - let full_path = format!("{}-{}", translator.get_locale(), full_path); + let full_path = format!("{}-{}", translator.get_locale(), full_path_without_locale); // Handle static initial state generation // We'll only write a static state if one is explicitly generated - if template.uses_build_state() { + // 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(full_path.clone()).await?; + let initial_state = template + .get_build_state(full_path_without_locale.clone()) + .await?; + // Write that intial state to a static JSON file + mutable_store + .write(&format!("static/{}.json", full_path), &initial_state) + .await?; + // Prerender the template using that state + let prerendered = sycamore::render_to_string(|| { + template.render_for_template( + Some(initial_state.clone()), + Rc::clone(&translator), + true, + ) + }); + // Write that prerendered HTML to a static file + mutable_store + .write(&format!("static/{}.html", full_path), &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(Some(initial_state), Rc::clone(&translator)); + mutable_store + .write(&format!("static/{}.head.html", full_path), &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(full_path_without_locale.clone()) + .await?; // Write that intial state to a static JSON file - config_manager + immutable_store .write(&format!("static/{}.json", full_path), &initial_state) .await?; // Prerender the template using that state @@ -77,13 +112,13 @@ pub async fn build_template( ) }); // Write that prerendered HTML to a static file - config_manager + immutable_store .write(&format!("static/{}.html", full_path), &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(Some(initial_state), Rc::clone(&translator)); - config_manager + immutable_store .write(&format!("static/{}.head.html", full_path), &head_str) .await?; } @@ -96,7 +131,7 @@ pub async fn build_template( // 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 - config_manager + mutable_store .write( &format!("static/{}.revld.txt", full_path), &datetime_to_revalidate.to_string(), @@ -115,10 +150,10 @@ pub async fn build_template( }); let head_str = template.render_head_str(None, Rc::clone(&translator)); // Write that prerendered HTML to a static file - config_manager + immutable_store .write(&format!("static/{}.html", full_path), &prerendered) .await?; - config_manager + immutable_store .write(&format!("static/{}.head.html", full_path), &head_str) .await?; } @@ -130,15 +165,20 @@ pub async fn build_template( async fn build_template_and_get_cfg( template: &Template<SsrNode>, translator: Rc<Translator>, - config_manager: &impl ConfigManager, + (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), 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, config_manager, exporting).await?; + let (pages, single_page) = build_template( + template, + translator, + (immutable_store, mutable_store), + 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()); @@ -168,7 +208,7 @@ async fn build_template_and_get_cfg( pub async fn build_templates_for_locale( templates: &[Template<SsrNode>], translator_raw: Translator, - config_manager: &impl ConfigManager, + (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), exporting: bool, ) -> Result<(), ServerError> { let translator = Rc::new(translator_raw); @@ -180,7 +220,7 @@ pub async fn build_templates_for_locale( futs.push(build_template_and_get_cfg( template, Rc::clone(&translator), - config_manager, + (immutable_store, mutable_store), exporting, )); } @@ -189,7 +229,7 @@ pub async fn build_templates_for_locale( render_cfg.extend(template_cfg.into_iter()) } - config_manager + immutable_store .write( "render_conf.json", &serde_json::to_string(&render_cfg).unwrap(), @@ -203,14 +243,20 @@ pub async fn build_templates_for_locale( async fn build_templates_and_translator_for_locale( templates: &[Template<SsrNode>], locale: String, - config_manager: &impl ConfigManager, + (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), translations_manager: &impl TranslationsManager, exporting: bool, ) -> Result<(), ServerError> { let translator = translations_manager .get_translator_for_locale(locale) .await?; - build_templates_for_locale(templates, translator, config_manager, exporting).await?; + build_templates_for_locale( + templates, + translator, + (immutable_store, mutable_store), + exporting, + ) + .await?; Ok(()) } @@ -220,7 +266,7 @@ async fn build_templates_and_translator_for_locale( pub async fn build_app( templates: Vec<Template<SsrNode>>, locales: &Locales, - config_manager: &impl ConfigManager, + (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), translations_manager: &impl TranslationsManager, exporting: bool, ) -> Result<(), ServerError> { @@ -231,7 +277,7 @@ pub async fn build_app( futs.push(build_templates_and_translator_for_locale( &templates, locale.to_string(), - config_manager, + (immutable_store, mutable_store), translations_manager, exporting, )); diff --git a/packages/perseus/src/config_manager.rs b/packages/perseus/src/config_manager.rs deleted file mode 100644 index 95cffc992b..0000000000 --- a/packages/perseus/src/config_manager.rs +++ /dev/null @@ -1,90 +0,0 @@ -// This file contains the logic for a universal interface to read and write to static files -// At simplest, this is just a filesystem interface, but it's more likely to be a CMS in production -// This has its own error management logic because the user may implement it separately - -use thiserror::Error; - -/// Errors that can occur in a config manager. -#[allow(missing_docs)] -#[derive(Error, Debug)] -pub enum ConfigManagerError { - #[error("asset '{name}' not found")] - NotFound { name: String }, - #[error("asset '{name}' couldn't be read")] - ReadFailed { - name: String, - #[source] - source: Box<dyn std::error::Error>, - }, - #[error("asset '{name}' couldn't be written")] - WriteFailed { - name: String, - #[source] - source: Box<dyn std::error::Error>, - }, -} - -use std::fs; - -/// A trait for systems that manage where to put configuration files. At simplest, we'll just write them to static files, but they're -/// more likely to be stored on a CMS. -#[async_trait::async_trait] -pub trait ConfigManager: Clone { - /// Reads data from the named asset. - async fn read(&self, name: &str) -> Result<String, ConfigManagerError>; - /// Writes data to the named asset. This will create a new asset if one doesn't exist already. - async fn write(&self, name: &str, content: &str) -> Result<(), ConfigManagerError>; -} - -/// The default config manager. This will store static files in the specified location on disk. This should be suitable for nearly all -/// development and serverful use-cases. Serverless is another matter though (more development needs to be done). -/// -/// Note: the `.write()` methods on this implementation will create any missing parent directories automatically. -#[derive(Clone)] -pub struct FsConfigManager { - root_path: String, -} -impl FsConfigManager { - /// Creates a new filesystem configuration manager. You should provide a path like `/dist` here. - pub fn new(root_path: String) -> Self { - Self { root_path } - } -} -#[async_trait::async_trait] -impl ConfigManager for FsConfigManager { - async fn read(&self, name: &str) -> Result<String, ConfigManagerError> { - let asset_path = format!("{}/{}", self.root_path, name); - match fs::metadata(&asset_path) { - Ok(_) => { - fs::read_to_string(&asset_path).map_err(|err| ConfigManagerError::ReadFailed { - name: asset_path, - source: err.into(), - }) - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Err(ConfigManagerError::NotFound { name: asset_path }) - } - Err(err) => { - return Err(ConfigManagerError::ReadFailed { - name: asset_path, - source: err.into(), - }) - } - } - } - // This creates a directory structure as necessary - async fn write(&self, name: &str, content: &str) -> Result<(), ConfigManagerError> { - let asset_path = format!("{}/{}", self.root_path, name); - let mut dir_tree: Vec<&str> = asset_path.split('/').collect(); - dir_tree.pop(); - - fs::create_dir_all(dir_tree.join("/")).map_err(|err| ConfigManagerError::WriteFailed { - name: asset_path.clone(), - source: err.into(), - })?; - fs::write(&asset_path, content).map_err(|err| ConfigManagerError::WriteFailed { - name: asset_path, - source: err.into(), - }) - } -} diff --git a/packages/perseus/src/errors.rs b/packages/perseus/src/errors.rs index 37f133bb5c..6a20c5d284 100644 --- a/packages/perseus/src/errors.rs +++ b/packages/perseus/src/errors.rs @@ -1,6 +1,5 @@ #![allow(missing_docs)] -use crate::config_manager::ConfigManagerError; use crate::translations_manager::TranslationsManagerError; use thiserror::Error; @@ -35,12 +34,11 @@ pub enum ServerError { template_name: String, cause: ErrorCause, // This will be triggered by the user's custom render functions, which should be able to have any error type - // TODO figure out custom error types on render functions #[source] source: Box<dyn std::error::Error>, }, #[error(transparent)] - ConfigManagerError(#[from] ConfigManagerError), + StoreError(#[from] StoreError), #[error(transparent)] TranslationsManagerError(#[from] TranslationsManagerError), #[error(transparent)] @@ -64,6 +62,25 @@ pub fn err_to_status_code(err: &ServerError) -> u16 { } } +/// Errors that can occur while reading from or writing to an immutable store. +#[derive(Error, Debug)] +pub enum StoreError { + #[error("asset '{name}' not found in store")] + NotFound { name: String }, + #[error("asset '{name}' couldn't be read from store")] + ReadFailed { + name: String, + #[source] + source: Box<dyn std::error::Error>, + }, + #[error("asset '{name}' couldn't be written to store")] + WriteFailed { + name: String, + #[source] + source: Box<dyn std::error::Error>, + }, +} + /// Errors that can occur while fetching a resource from the server. #[derive(Error, Debug)] pub enum FetchError { diff --git a/packages/perseus/src/export.rs b/packages/perseus/src/export.rs index 543353a8df..241b816970 100644 --- a/packages/perseus/src/export.rs +++ b/packages/perseus/src/export.rs @@ -2,7 +2,7 @@ use crate::errors::*; use crate::get_render_cfg; use crate::html_shell::{interpolate_page_data, prep_html_shell}; use crate::serve::PageData; -use crate::ConfigManager; +use crate::stores::ImmutableStore; use crate::Locales; use crate::SsrNode; use crate::TemplateMap; @@ -13,18 +13,18 @@ use std::fs; async fn get_static_page_data( path: &str, has_state: bool, - config_manager: &impl ConfigManager, + immutable_store: &ImmutableStore, ) -> Result<PageData, ServerError> { // Get the partial HTML content and a state to go with it (if applicable) - let content = config_manager + let content = immutable_store .read(&format!("static/{}.html", path)) .await?; - let head = config_manager + let head = immutable_store .read(&format!("static/{}.head.html", path)) .await?; let state = match has_state { true => Some( - config_manager + immutable_store .read(&format!("static/{}.json", path)) .await?, ), @@ -46,11 +46,11 @@ pub async fn export_app( html_shell_path: &str, locales: &Locales, root_id: &str, - config_manager: &impl ConfigManager, + immutable_store: &ImmutableStore, translations_manager: &impl TranslationsManager, ) -> 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(config_manager).await?; + let render_cfg = get_render_cfg(immutable_store).await?; // Get the HTML shell and prepare it by interpolating necessary values let raw_html_shell = fs::read_to_string(html_shell_path).map_err(|err| BuildError::HtmlShellNotFound { @@ -77,7 +77,7 @@ pub async fn export_app( // 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 if locales.using_i18n { - config_manager + immutable_store .write(&format!("exported/{}.html", path), &html_shell) .await?; } @@ -89,20 +89,20 @@ pub async fn export_app( let page_data = get_static_page_data( &format!("{}-{}", locale, &path_encoded), has_state, - config_manager, + 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 = interpolate_page_data(&html_shell, &page_data, root_id); // We don't add an extension because this will be queried directly - config_manager + immutable_store .write(&format!("exported/{}/{}.html", locale, &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 = serde_json::to_string(&page_data).unwrap(); - config_manager + immutable_store .write( &format!("exported/.perseus/page/{}/{}.json", locale, &path_encoded), &partial, @@ -113,20 +113,20 @@ pub async fn export_app( let page_data = get_static_page_data( &format!("{}-{}", locales.default, &path_encoded), has_state, - config_manager, + 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 = interpolate_page_data(&html_shell, &page_data, root_id); // We don't add an extension because this will be queried directly by the browser - config_manager + immutable_store .write(&format!("exported/{}.html", &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 = serde_json::to_string(&page_data).unwrap(); - config_manager + immutable_store .write( &format!( "exported/.perseus/page/{}/{}.json", @@ -145,7 +145,7 @@ pub async fn export_app( .get_translations_str_for_locale(locale.to_string()) .await?; // Write it to an asset so that it can be served directly - config_manager + immutable_store .write( &format!("exported/.perseus/translations/{}", locale), &translations_str, diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index 7a5f239515..edb59cd2a0 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -38,8 +38,6 @@ /// Utilities for building your app. pub mod build; mod client_translations_manager; -/// Utilities for creating custom config managers, as well as the default `FsConfigManager`. -pub mod config_manager; mod decode_time_str; mod default_headers; /// Utilities regarding the formation of error pages for HTTP status codes, like a `404 Not Found` page. @@ -59,6 +57,8 @@ pub mod router; pub mod serve; /// Utilities to do with the app shell. You probably don't want to delve into here. pub mod shell; +/// Utilities for mutable/immutable store managers. See the book for more details on this. +pub mod stores; /// Utilities to do with templating. This is where the bulk of designing apps lies. pub mod template; mod test; @@ -76,7 +76,6 @@ pub use sycamore_router::Route; pub use crate::build::{build_app, build_template, build_templates_for_locale}; pub use crate::client_translations_manager::ClientTranslationsManager; -pub use crate::config_manager::{ConfigManager, FsConfigManager}; pub use crate::error_pages::ErrorPages; pub use crate::errors::{err_to_status_code, ErrorCause, GenericErrorWithCause}; pub use crate::export::export_app; diff --git a/packages/perseus/src/macros.rs b/packages/perseus/src/macros.rs index ea3fd0e97b..2e19aedc31 100644 --- a/packages/perseus/src/macros.rs +++ b/packages/perseus/src/macros.rs @@ -1,16 +1,35 @@ -/// An internal macro used for defining a function to get the user's preferred config manager (which requires multiple branches). +/// An internal macro used for defining a function to get the user's preferred immutable store (which requires multiple branches). #[doc(hidden)] #[macro_export] -macro_rules! define_get_config_manager { +macro_rules! define_get_immutable_store { () => { - pub fn get_config_manager() -> impl $crate::ConfigManager { + pub fn get_immutable_store() -> $crate::stores::ImmutableStore { // This will be executed in the context of the user's directory, but moved into `.perseus` - $crate::FsConfigManager::new("./dist".to_string()) + // If we're in prod mode on the server though, this is fine too + $crate::stores::ImmutableStore::new("./dist".to_string()) } }; - ($config_manager:expr) => { - pub fn get_config_manager() -> impl $crate::ConfigManager { - $config_manager + ($dist_path:literal) => { + pub fn get_immutable_store() -> $crate::stores::ImmutableStore { + $crate::stores::ImmutableStore::new($dist_path.to_string()) + } + }; +} +/// An internal macro used for defining a function to get the user's preferred mutable store (which requires multiple branches). +#[doc(hidden)] +#[macro_export] +macro_rules! define_get_mutable_store { + () => { + pub fn get_mutable_store() -> impl $crate::stores::MutableStore { + // This will be executed in the context of the user's directory, but moved into `.perseus` + // If we're in prod mode on the server though, this is fine too + // Note that this is separated out from the immutable store deliberately + $crate::stores::FsMutableStore::new("./dist/mutable".to_string()) + } + }; + ($mutable_store:expr) => { + pub fn get_mutable_store() -> impl $crate::stores::MutableStore { + $mutable_store } }; } @@ -42,8 +61,14 @@ macro_rules! define_get_translations_manager { .cloned() .cloned() .collect(); + // If we're running on a server, we should be using a flattened directory structure + let translations_dir = if ::std::env::var("PERSEUS_STANDALONE").is_ok() { + "./translations" + } else { + "../translations" + }; $crate::FsTranslationsManager::new( - "../translations".to_string(), + translations_dir.to_string(), all_locales, $crate::TRANSLATOR_FILE_EXT.to_string(), ) @@ -124,10 +149,20 @@ macro_rules! define_get_static_aliases { panic!("it's a security risk to include paths outside the current directory in `static_aliases`"); } else if resource.starts_with("./") { // `./` -> `../` (moving to execution from `.perseus/`) - format!(".{}", resource) + // But if we're operating standalone, it stays the same + if ::std::env::var("PERSEUS_STANDALONE").is_ok() { + resource + } else { + format!(".{}", resource) + } } else { // Anything else gets a `../` prepended - format!("../{}", resource) + // But if we're operating standalone, it stays the same + if ::std::env::var("PERSEUS_STANDALONE").is_ok() { + resource + } else { + format!("../{}", resource) + } }; static_aliases.insert($url.to_string(), resource); )* @@ -146,7 +181,7 @@ macro_rules! define_get_static_aliases { /// compatibility with the Perseus CLI significantly easier. /// /// Warning: all properties must currently be in the correct order (`root`, `templates`, `error_pages`, `locales`, `static_aliases`, -/// `config_manager`, `translations_manager`). +/// `dist_path`, `mutable_store`, `translations_manager`). #[macro_export] macro_rules! define_app { // With locales @@ -165,7 +200,8 @@ macro_rules! define_app { $(,static_aliases: { $($url:literal => $resource:literal)* })? - $(,config_manager: $config_manager:expr)? + $(,dist_path: $dist_path:literal)? + $(,mutable_store: $mutable_store:expr)? $(,translations_manager: $translations_manager:expr)? } => { $crate::define_app!( @@ -184,7 +220,8 @@ macro_rules! define_app { $(,static_aliases: { $($url => $resource)* })? - $(,config_manager: $config_manager)? + $(,dist_path: $dist_path)? + $(,mutable_store: $mutable_store)? $(,translations_manager: $translations_manager)? } ); @@ -199,7 +236,8 @@ macro_rules! define_app { $(,static_aliases: { $($url:literal => $resource:literal)* })? - $(,config_manager: $config_manager:expr)? + $(,dist_path: $dist_path:literal)? + $(,mutable_store: $mutable_store:expr)? } => { $crate::define_app!( @define_app, @@ -218,7 +256,8 @@ macro_rules! define_app { $(,static_aliases: { $($url => $resource)* })? - $(,config_manager: $config_manager)? + $(,dist_path: $dist_path)? + $(,mutable_store: $mutable_store)? } ); }; @@ -242,7 +281,8 @@ macro_rules! define_app { $(,static_aliases: { $($url:literal => $resource:literal)* })? - $(,config_manager: $config_manager:expr)? + $(,dist_path: $dist_path:literal)? + $(,mutable_store: $mutable_store:expr)? $(,translations_manager: $translations_manager:expr)? } ) => { @@ -250,9 +290,11 @@ macro_rules! define_app { /// the form <div id="root_id">` in your markup (double or single quotes, `root_id` replaced by what this property is set to). $crate::define_app_root!($($root_selector)?); - /// Gets the config manager to use. This allows the user to conveniently test production managers in development. If nothing is - /// given, the filesystem will be used. - $crate::define_get_config_manager!($($config_manager)?); + /// Gets the immutable store to use. This allows the user to conveniently change the path of distribution artifacts. + $crate::define_get_immutable_store!($($dist_path)?); + /// Gets the mutable store to use. This allows the user to conveniently substitute the default filesystem store for another + /// one in development and production. + $crate::define_get_mutable_store!($($mutable_store)?); /// Gets the translations manager to use. This allows the user to conveniently test production managers in development. If /// nothing is given, the filesystem will be used. diff --git a/packages/perseus/src/router.rs b/packages/perseus/src/router.rs index 21d743c98e..147cf894b1 100644 --- a/packages/perseus/src/router.rs +++ b/packages/perseus/src/router.rs @@ -4,21 +4,26 @@ use crate::TemplateMap; use std::collections::HashMap; use sycamore::prelude::GenericNode; -/// Determines the template to use for the given path by checking against the render configuration. This houses the central routing -/// algorithm of Perseus, which is based fully on the fact that we know about every single page except those rendered with ISR, and we -/// can infer about them based on template root path domains. If that domain system is violated, this routing algorithm will not -/// behave as expected whatsoever (as far as routing goes, it's undefined behaviour)! +/// Determines the template to use for the given path by checking against the render configuration., also returning whether we matched +/// a simple page or an incrementally-generated one (`true` for incrementally generated). Note that simple pages include those on +/// incrementally-generated templates that we pre-rendered with *build paths* at build-time (and are hence in an immutable store rather +/// than a mutable store). +/// +/// This houses the central routing algorithm of Perseus, which is based fully on the fact that we know about every single page except +/// those rendered with ISR, and we can infer about them based on template root path domains. If that domain system is violated, this +/// routing algorithm will not behave as expected whatsoever (as far as routing goes, it's undefined behaviour)! pub fn get_template_for_path<'a, G: GenericNode>( raw_path: &str, render_cfg: &HashMap<String, String>, templates: &'a TemplateMap<G>, -) -> Option<&'a Template<G>> { +) -> (Option<&'a Template<G>>, bool) { let mut path = raw_path; // If the path is empty, we're looking for the special `index` page if path.is_empty() { path = "index"; } + let mut was_incremental_match = false; // Match the path to one of the templates let mut template_name = String::new(); // We'll try a direct match first @@ -36,6 +41,7 @@ pub fn get_template_for_path<'a, G: GenericNode>( // If we find something, keep going until we don't (maximise specificity) if let Some(template_root_path) = render_cfg.get(&path_to_try) { + was_incremental_match = true; template_name = template_root_path.to_string(); } else { break; @@ -44,11 +50,11 @@ pub fn get_template_for_path<'a, G: GenericNode>( } // If we still have nothing, then the page doesn't exist if template_name.is_empty() { - return None; + return (None, was_incremental_match); } // Get the template to use (the `Option<T>` this returns is perfect) if it exists - templates.get(&template_name) + (templates.get(&template_name), was_incremental_match) } /// Matches the given path to a `RouteVerdict`. This takes a `TemplateMap` to match against, the render configuration to index, and it @@ -72,13 +78,15 @@ pub fn match_route<G: GenericNode>( // 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 = get_template_for_path(&path_without_locale, render_cfg, templates); + let (template, was_incremental_match) = + get_template_for_path(&path_without_locale, render_cfg, templates); verdict = match template { Some(template) => RouteVerdict::Found(RouteInfo { locale: locale.to_string(), // This will be used in asset fetching from the server path: path_without_locale, template: template.clone(), + was_incremental_match, }), None => RouteVerdict::NotFound, }; @@ -93,13 +101,15 @@ pub fn match_route<G: GenericNode>( verdict = RouteVerdict::LocaleDetection(path_joined); } else { // Get the template to use - let template = get_template_for_path(&path_joined, render_cfg, templates); + 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: template.clone(), + was_incremental_match, }), None => RouteVerdict::NotFound, }; @@ -115,6 +125,9 @@ pub struct RouteInfo<G: GenericNode> { pub path: String, /// The template that will be used. The app shell will derive pros and a translator to pass to the template function. pub template: Template<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 details. + pub was_incremental_match: bool, /// The locale for the template to be rendered in. pub locale: String, } diff --git a/packages/perseus/src/serve.rs b/packages/perseus/src/serve.rs index fb27a8403d..94a8cd1e9f 100644 --- a/packages/perseus/src/serve.rs +++ b/packages/perseus/src/serve.rs @@ -1,8 +1,8 @@ // This file contains the universal logic for a serving process, regardless of framework -use crate::config_manager::ConfigManager; use crate::decode_time_str::decode_time_str; use crate::errors::*; +use crate::stores::{ImmutableStore, MutableStore}; use crate::template::{States, Template, TemplateMap}; use crate::Request; use crate::TranslationsManager; @@ -25,11 +25,11 @@ pub struct PageData { pub head: String, } -/// Gets the configuration of how to render each page. +/// Gets the configuration of how to render each page using an immutable store. pub async fn get_render_cfg( - config_manager: &impl ConfigManager, + immutable_store: &ImmutableStore, ) -> Result<HashMap<String, String>, ServerError> { - let content = config_manager.read("render_conf.json").await?; + 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(); @@ -39,20 +39,21 @@ pub async fn get_render_cfg( Ok(cfg) } -/// Renders a template that uses state generated at build-time. +/// 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. async fn render_build_state( path_encoded: &str, - config_manager: &impl ConfigManager, + immutable_store: &ImmutableStore, ) -> Result<(String, String, Option<String>), ServerError> { // Get the static HTML - let html = config_manager + let html = immutable_store .read(&format!("static/{}.html", path_encoded)) .await?; - let head = config_manager + let head = immutable_store .read(&format!("static/{}.head.html", path_encoded)) .await?; // Get the static JSON - let state = match config_manager + let state = match immutable_store .read(&format!("static/{}.json", path_encoded)) .await { @@ -62,7 +63,32 @@ async fn render_build_state( Ok((html, head, state)) } -/// Renders a template that generated its state at request-time. Note that revalidation and ISR have no impact on SSR-rendered pages. +/// 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. +async fn render_build_state_for_mutable( + path_encoded: &str, + mutable_store: &impl MutableStore, +) -> Result<(String, String, Option<String>), ServerError> { + // Get the static HTML + let html = mutable_store + .read(&format!("static/{}.html", path_encoded)) + .await?; + 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) => Some(state), + Err(_) => None, + }; + + 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. async fn render_request_state( template: &Template<SsrNode>, translator: Rc<Translator>, @@ -79,12 +105,14 @@ async fn render_request_state( Ok((html, head, state)) } -/// Checks if a template that uses ISR has already been cached. +/// 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. async fn get_incremental_cached( path_encoded: &str, - config_manager: &impl ConfigManager, + mutable_store: &impl MutableStore, ) -> Option<(String, String)> { - let html_res = config_manager + let html_res = mutable_store .read(&format!("static/{}.html", path_encoded)) .await; @@ -92,8 +120,8 @@ async fn get_incremental_cached( match html_res { Ok(html) if !cfg!(debug_assertions) => { // If the HTML exists, the head must as well - let head = config_manager - .read(&format!("static/{}.html", path_encoded)) + let head = mutable_store + .read(&format!("static/{}.head.html", path_encoded)) .await .unwrap(); Some((html, head)) @@ -101,17 +129,19 @@ async fn get_incremental_cached( Ok(_) | Err(_) => None, } } -/// Checks if a template should revalidate by time. +/// 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, - config_manager: &impl ConfigManager, + mutable_store: &impl MutableStore, ) -> Result<bool, ServerError> { let mut should_revalidate = false; // If it revalidates after a certain period of time, we needd to check that BEFORE the custom logic if template.revalidates_with_time() { // Get the time when it should revalidate (RFC 3339) - let datetime_to_revalidate_str = config_manager + // 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) @@ -135,13 +165,14 @@ async fn should_revalidate( } Ok(should_revalidate) } -/// Revalidates a template +/// Revalidates a template. All information about templates that revalidate (timestamp, content. head, and state) is stored in a +/// mutable store, so that's what this function uses. async fn revalidate( template: &Template<SsrNode>, translator: Rc<Translator>, path: &str, path_encoded: &str, - config_manager: &impl ConfigManager, + mutable_store: &impl MutableStore, ) -> Result<(String, String, Option<String>), ServerError> { // We need to regenerate and cache this page for future usage (until the next revalidation) let state = Some( @@ -159,23 +190,23 @@ async fn revalidate( // 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 = decode_time_str(&template.get_revalidate_interval().unwrap())?; - config_manager + mutable_store .write( &format!("static/{}.revld.txt", path_encoded), &datetime_to_revalidate, ) .await?; } - config_manager + mutable_store .write( &format!("static/{}.json", path_encoded), &state.clone().unwrap(), ) .await?; - config_manager + mutable_store .write(&format!("static/{}.html", path_encoded), &html) .await?; - config_manager + mutable_store .write(&format!("static/{}.head.html", path_encoded), &head) .await?; @@ -183,15 +214,18 @@ async fn revalidate( } /// 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). +/// 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. // TODO possible further optimizations on this for futures? pub async fn get_page_for_template( // This must not contain the locale raw_path: &str, locale: &str, template: &Template<SsrNode>, + // This allows us to differentiate pages for incrementally generated templates that were pre-rendered with build paths (and are in the immutable store) from those generated and cached at runtime (in the mutable store) + was_incremental_match: bool, req: Request, - config_manager: &impl ConfigManager, + (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), translations_manager: &impl TranslationsManager, ) -> Result<PageData, ServerError> { // Get a translator for this locale (for sanity we hope the manager is caching) @@ -219,20 +253,22 @@ pub async fn get_page_for_template( // 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() { + // TODO separate out build paths pages, which are in the immutable store + 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, config_manager).await; + let html_and_head_opt = get_incremental_cached(&path_encoded, mutable_store).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, config_manager).await? { + if should_revalidate(template, &path_encoded, mutable_store).await? { let (html_val, head_val, state) = revalidate( template, Rc::clone(&translator), path, &path_encoded, - config_manager, + mutable_store, ) .await?; // Build-time generated HTML is the lowest priority, so we'll only set it if nothing else already has @@ -248,7 +284,7 @@ pub async fn get_page_for_template( head = head_val; } // Get the static JSON (if it exists, but it should) - states.build_state = match config_manager + states.build_state = match mutable_store .read(&format!("static/{}.json", path_encoded)) .await { @@ -258,6 +294,7 @@ pub async fn get_page_for_template( } } // 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 let state = Some(template.get_build_state(path.to_string()).await?); @@ -273,7 +310,7 @@ pub async fn get_page_for_template( decode_time_str(&template.get_revalidate_interval().unwrap())?; // 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 - config_manager + mutable_store .write( &format!("static/{}.revld.txt", path_encoded), &datetime_to_revalidate, @@ -281,17 +318,17 @@ pub async fn get_page_for_template( .await?; } // Cache all that - config_manager + mutable_store .write( &format!("static/{}.json", path_encoded), &state.clone().unwrap(), ) .await?; // Write that prerendered HTML to a static file - config_manager + mutable_store .write(&format!("static/{}.html", path_encoded), &html_val) .await?; - config_manager + mutable_store .write(&format!("static/{}.head.html", path_encoded), &head_val) .await?; @@ -304,14 +341,17 @@ pub async fn get_page_for_template( } } } 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 - if should_revalidate(template, &path_encoded, config_manager).await? { + // It'll be in the mutable store if we do + if should_revalidate(template, &path_encoded, mutable_store).await? { let (html_val, head_val, state) = revalidate( template, Rc::clone(&translator), path, &path_encoded, - config_manager, + mutable_store, ) .await?; // Build-time generated HTML is the lowest priority, so we'll only set it if nothing else already has @@ -320,9 +360,21 @@ pub async fn get_page_for_template( 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 + let (html_val, head_val, state) = + render_build_state_for_mutable(&path_encoded, mutable_store).await?; + // Build-time generated HTML is the lowest priority, so we'll only set it if nothing else already has + if html.is_empty() { + html = html_val; + 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 let (html_val, head_val, state) = - render_build_state(&path_encoded, config_manager).await?; + render_build_state(&path_encoded, immutable_store).await?; // Build-time generated HTML is the lowest priority, so we'll only set it if nothing else already has if html.is_empty() { html = html_val; @@ -366,16 +418,16 @@ pub async fn get_page_for_template( Ok(res) } -/// Gets the HTML/JSON data for the given page path. This will call SSG/SSR/etc., whatever is needed for that page. Note that HTML generated -/// at request-time will **always** replace anything generated at build-time, incrementally, revalidated, etc. +/// Gets the HTML/JSON data for the given page path. This will call SSG/SSR/etc., whatever is needed for that page. Note that HTML +/// generated at request-time will **always** replace anything generated at build-time, incrementally, revalidated, etc. pub async fn get_page( // This must not contain the locale raw_path: &str, locale: &str, - template_name: &str, + (template_name, was_incremental_match): (&str, bool), req: Request, templates: &TemplateMap<SsrNode>, - config_manager: &impl ConfigManager, + (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), translations_manager: &impl TranslationsManager, ) -> Result<PageData, ServerError> { let mut path = raw_path; @@ -400,8 +452,9 @@ pub async fn get_page( raw_path, locale, template, + was_incremental_match, req, - config_manager, + (immutable_store, mutable_store), translations_manager, ) .await?; diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs index 898125af61..bc84a10a2f 100644 --- a/packages/perseus/src/shell.rs +++ b/packages/perseus/src/shell.rs @@ -214,7 +214,7 @@ pub enum InitialState { // TODO handle exceptions higher up pub async fn app_shell( path: String, - template: Template<DomNode>, + (template, was_incremental_match): (Template<DomNode>, bool), locale: String, translations_manager: Rc<RefCell<ClientTranslationsManager>>, error_pages: Rc<ErrorPages<DomNode>>, @@ -288,10 +288,11 @@ pub async fn app_shell( }; // Get the static page data let asset_url = format!( - "/.perseus/page/{}/{}.json?template_name={}", + "/.perseus/page/{}/{}.json?template_name={}&was_incremental_match={}", locale, path.to_string(), - template.get_path() + 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; diff --git a/packages/perseus/src/stores/immutable.rs b/packages/perseus/src/stores/immutable.rs new file mode 100644 index 0000000000..bcfa23ae53 --- /dev/null +++ b/packages/perseus/src/stores/immutable.rs @@ -0,0 +1,51 @@ +use crate::errors::*; +use std::fs; + +/// An immutable storage system. This wraps filesystem calls in a sensible asynchronous API, allowing abstraction of the base path +/// to a distribution directory or the like. Perseus uses this to store assts created at build time that won't change, which is +/// anything not involved in the *revalidation* or *incremental generation* strategies. +/// +/// Note: the `.write()` methods on this implementation will create any missing parent directories automatically. +#[derive(Clone)] +pub struct ImmutableStore { + root_path: String, +} +impl ImmutableStore { + /// Creates a new immutable store. You should provide a path like `dist/` here. + pub fn new(root_path: String) -> Self { + Self { root_path } + } + /// Reads the given asset from the filesystem asynchronously. + pub async fn read(&self, name: &str) -> Result<String, StoreError> { + let asset_path = format!("{}/{}", self.root_path, name); + match fs::metadata(&asset_path) { + Ok(_) => fs::read_to_string(&asset_path).map_err(|err| StoreError::ReadFailed { + name: asset_path, + source: err.into(), + }), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Err(StoreError::NotFound { name: asset_path }) + } + Err(err) => Err(StoreError::ReadFailed { + name: asset_path, + source: err.into(), + }), + } + } + /// Writes the given asset to the filesystem asynchronously. This must only be used at build-time, and must not be changed + /// afterward. + pub async fn write(&self, name: &str, content: &str) -> Result<(), StoreError> { + let asset_path = format!("{}/{}", self.root_path, name); + let mut dir_tree: Vec<&str> = asset_path.split('/').collect(); + dir_tree.pop(); + + fs::create_dir_all(dir_tree.join("/")).map_err(|err| StoreError::WriteFailed { + name: asset_path.clone(), + source: err.into(), + })?; + fs::write(&asset_path, content).map_err(|err| StoreError::WriteFailed { + name: asset_path, + source: err.into(), + }) + } +} diff --git a/packages/perseus/src/stores/mod.rs b/packages/perseus/src/stores/mod.rs new file mode 100644 index 0000000000..168754e2d6 --- /dev/null +++ b/packages/perseus/src/stores/mod.rs @@ -0,0 +1,7 @@ +/// Utilities for working with immutable stores. +pub mod immutable; +/// Utilities for working with mutable stores. +pub mod mutable; + +pub use immutable::ImmutableStore; +pub use mutable::{FsMutableStore, MutableStore}; diff --git a/packages/perseus/src/stores/mutable.rs b/packages/perseus/src/stores/mutable.rs new file mode 100644 index 0000000000..1635097bd8 --- /dev/null +++ b/packages/perseus/src/stores/mutable.rs @@ -0,0 +1,65 @@ +use crate::errors::*; +use std::fs; + +/// A trait for mutable stores. This is abstracted away so that users can implement a non-filesystem mutable store, which is useful +/// for read-only filesystem environments, as on many modern hosting providers. See the book for further details on this subject. +#[async_trait::async_trait] +pub trait MutableStore: Clone { + /// Reads data from the named asset. + async fn read(&self, name: &str) -> Result<String, StoreError>; + /// Writes data to the named asset. This will create a new asset if one doesn't exist already. + async fn write(&self, name: &str, content: &str) -> Result<(), StoreError>; +} + +/// The default mutable store, which simply uses the filesystem. This is suitable for development and production environments with +/// writable filesystems (in which it's advised), but this is of course not usable on production read-only filesystems, and another +/// implementation of `MutableStore` should be preferred. +/// +/// Note: the `.write()` methods on this implementation will create any missing parent directories automatically. +#[derive(Clone)] +pub struct FsMutableStore { + root_path: String, +} +impl FsMutableStore { + /// Creates a new filesystem configuration manager. You should provide a path like `/dist/mutable` here. Make sure that this is + /// not the same path as the immutable store, as this will cause potentially problematic overlap between the two systems. + pub fn new(root_path: String) -> Self { + Self { root_path } + } +} +#[async_trait::async_trait] +impl MutableStore for FsMutableStore { + async fn read(&self, name: &str) -> Result<String, StoreError> { + let asset_path = format!("{}/{}", self.root_path, name); + match fs::metadata(&asset_path) { + Ok(_) => fs::read_to_string(&asset_path).map_err(|err| StoreError::ReadFailed { + name: asset_path, + source: err.into(), + }), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(StoreError::NotFound { name: asset_path }) + } + Err(err) => { + return Err(StoreError::ReadFailed { + name: asset_path, + source: err.into(), + }) + } + } + } + // This creates a directory structure as necessary + async fn write(&self, name: &str, content: &str) -> Result<(), StoreError> { + let asset_path = format!("{}/{}", self.root_path, name); + let mut dir_tree: Vec<&str> = asset_path.split('/').collect(); + dir_tree.pop(); + + fs::create_dir_all(dir_tree.join("/")).map_err(|err| StoreError::WriteFailed { + name: asset_path.clone(), + source: err.into(), + })?; + fs::write(&asset_path, content).map_err(|err| StoreError::WriteFailed { + name: asset_path, + source: err.into(), + }) + } +} diff --git a/packages/perseus/src/template.rs b/packages/perseus/src/template.rs index 6bee1edb09..d7cd741d13 100644 --- a/packages/perseus/src/template.rs +++ b/packages/perseus/src/template.rs @@ -209,7 +209,6 @@ impl<G: GenericNode> Template<G> { // Unlike `template`, this may not be set at all (especially in very simple apps) head: Rc::new(|_: Option<String>| sycamore::template! {}), // We create sensible header defaults here - // TODO header defaults set_headers: Rc::new(|_: Option<String>| default_headers()), get_build_paths: None, incremental_generation: false, diff --git a/packages/perseus/src/translator/errors.rs b/packages/perseus/src/translator/errors.rs index ba38ceaa43..6ad4cafd74 100644 --- a/packages/perseus/src/translator/errors.rs +++ b/packages/perseus/src/translator/errors.rs @@ -10,7 +10,6 @@ pub enum TranslatorError { #[error("translations string for locale '{locale}' couldn't be parsed")] TranslationsStrSerFailed { locale: String, - // TODO #[source] source: Box<dyn std::error::Error>, },