Core Principles

Before you dive into Perseus, you might want to get a better idea of the fundamentals on which the framework is built. If you'd prefer to dive straight in though, check out the tutorial, and then maybe come back here later.

The main key idea that underpins Perseus is about templates, and the primary architectural matter to understand is how Perseus apps actually work in terms of their components.

Templates

Templates are the key to understanding Perseus code. Once you do, you should be able to confidently write clear code for apps that do exactly what you want them to. Nicely, this core concept also correlates with the file of code that defines the majority of the inner workings of Perseus (which is 600 lines long...).

There are two things you need to know about templates:

  1. An app is split into templates, and each template is split into pages.
  2. A page is generated from a template and state. Template + state = page

Anyone who's ever used a website before will be at least passingly familiar with the idea of pages --- they're things that display different content, each at a different route. For example, you might have a landing page at https://example.com and an about page at https://example.com/about.

In Perseus, pages are never coded directly, they're generated by the engine from templates. Templates can be thought of as mathematical functions if you like: (crudely) a template T can be defined such that T(x) = P, where x is some state, and P is a page.

Let's take an example to understand how this works in practice. Let's say you're building a music player app that has a vast library of songs (we'll ignore playlists, artists, etc. to keep things simple). The first step in designing your app is thinking about its structure. It comes fairly quickly that you'll need an index page to show the top songs, an about page to tell people about the platform, and one page for each song. Now, the index and about pages have different structures, but every song page has the same structure, just with different content. This is where templates come in. You would have one template for the index page and another for the about page, and then you'd have a third template for the songs pages.

That third template can take in some state, and produce a different page for every single song, but all with the same structure. You can see this kind of concept in action on this very website. Every page in the docs has the same heading up the top, footer down the bottom, and sidebar on the left (or in a menu if you're on mobile), but they all have different content. There's just one template involved for all this, which generates hundreds of pages (here, that same template generates pages for every version of Perseus ever).

So what about those first two? Well, they're very simple templates that don't take any state at all --- they can only produce one page. To take our crude mathematical definition, T() = P for these, and, since T takes no arguments, it can only produce the same page every time.

This illustrates nicely that the determining factor that differentiates pages from each other is state, and that's what Perseus is built around.

Let's return to our music player app. Are all those songs listed in a database available at build-time? Use the build state strategy. Are there too many to build all at once? Use incremental generation to build only the most commonly used songs first, and then build the rest on-demand when they're first accessed, caching to make them fast for subsequent users.

Once that state is generated, Perseus will go right ahead and proactively prerender your pages to HTML, meaning your users see content the second they load your site. (This is called server-side rendering, except the actual rendering has happened ahead of time, whenever you built your app.)

These ideas are built into Perseus at the core, and generating state for templates to generate pages is the fundamental idea behind Perseus. You'll find similar concepts in popular JavaScript frameworks like NextJS and GatsbyJS. It's Perseus' speed, ergonomics, and some things we'll explain in a moment that set it apart.

Once you've generated some state and you've got all the pages ready, there's still a log of work to be done on this music player app. A given song might be paused or playing, the user might have manually turned off dark mode, autoplaying related songs might be on or off. This is all state, but it's not state that we can handle when we build your app. Traditionally, frameworks would leave you on your own here to work this all out, but Perseus tries to be a little more helpful by automatically making your state reactive. Let's say the state for a single song page includes the properties name, artist, album, year, and paused (there'd probably be a lot more in reality though!). The first four can be set at build time and forgotten about, but paused could be changed at any time. No problem, you can change it once the page is loaded. Just call .set() on it and Perseus will not only update it locally, but it will update it in a store global to your app so that, if a user goes back to that song later, it will be preserved (or not, your choice). And what about things like dark_mode, state that's relevant to the whole app? Well, Perseus provides inbuilt support for reactive global state that can be interacted with from any page.

Now, if you're familiar with user interface (UI) development, this might all sound familiar to you, it's very similar to the MVC, or model, view, controller pattern. If you've never heard of this, it's just a way of developing apps in which you hold all the states that your app can possibly be in in a model and use that to build a view. Then you handle user interaction with a controller, which modifies the state, and the view updates accordingly. Perseus doesn't force this structure on you, and in fact you can opt out entirely from all this reactive state business if it's not your cup of tea with no problems, because Perseus doesn't use MVC as a pattern that you develop in, it uses it as an architecture that your code works in. You can use development patterns from 1960 or 2060 if you want, Perseus doesn't mind, it'll just work away in the background and make sure your app's state just works.

Perseus also adds a little twist to the usual ideas of app state. If your entire app is represented in its state, then wouldn't it be cool if you could freeze that state, save it somewhere, and then boot it back up later to bring your app to exactly where it was before? This is inbuilt into Perseus, and it's still insanely fast. But, if you don't want it, you can turn it off, no problem.

THis does let you do some really cool stuff though, like bringing a user back to exactly where they left off when they log back into your app, down to the last character they typed into an input, with only a few lines of code. (You store a string, Perseus handles the freezing and thawing.)

Architecture

When you write a Perseus app, you'll usually just define a main() function annotated with #[perseus::main(...)], but this does some important things in the background. Specifically, it actually creates three functions: one that returns your PerseusApp, and then two new main() functions: one for the engine, and another for the browser. That distinction is one you should get used to, because it pervades Perseus. Unfortunately, most other frameworks try to shove this away behind some abstractions, which leads to confusing dynamics about where a function should actually be run. Perseus tries to make this as clear as possible.

Before we can go any further into this though, we'll need to define the engine, because it's a Perseus-specific term. Usually, people would refer to the server-side, but this term was avoided for Perseus to make clear that the server is just a single part of the engine. The engine is made up of these components:

  • Builder --- builds your app, generating some stuff in dist/
  • Exporter --- goes a few steps further than the builder, structuring your app for serving as a flat file structure, with no explicit server
  • Server --- serves the built artifacts in dist/, executing certain server-side logic as necessary
  • Error page exporter --- exports a single error page to a static file (e.g. you'll need this if you want your custom error pages to work on GitHub Pages or similar hosts)
  • Tinker --- runs a certain type of plugin (more on this later)

So, when we talk about engine-side, we mean this! The reason these are all lumped together is because they're all actually one binary, which is told what exact action to perform by a special environment variable automatically set by the CLI. So, when you run perseus export and perseus serve, those are actually basically both doing the exact same thing, just with a different environment variable setting!

As for the browser-side, this is just the code that runs on the wasm32-unknown-unknown target (yes, those unknowns are supposed to be there!), which is Rust's way of talking about the browser.

So, when we use the #[perseus::main(...)] macro, that's creating a function that returns your PerseusApp, and another called main() for the server (which is annotated with #[tokio::main] to make it asynchronous), and another called main() for the client (annotated with #[wasm_bindgen::prelude::wasm_bindgen] to make it discoverable by the browser).

What's nice about this architecture is that you can do it yourself without the macro! In fact, if you want to do more advanced things, like setting up custom API routes, this is the best way to go. Then, you would use the #[perseus::engine_main] and #[perseus::browser_main] annotations to make your life easier. (Or, you could avoid them and do their work yourself, which is very straightforward.)

The key thing here is that you can easily use this more advanced structure to gain greater control over your app, without sacrificing any performance. From here, you can also gain greater control over any part of your app's build process, which makes Perseus practically infinitely customizable to do exactly what you want!

The upshot of all this is that Perseus is actually creating two separate entrypoints, one for the engine-side, and another for the browser-side. Crucially, both use the same PerseusApp, which is a universal way of defining your app's components, like templates. (You don't need to know this, but it actually does slightly different things on the browser and engine-sides itself to optimize your app.)

Why do you need to know all this? Because it makes it much easier to understand how to expand your app's capabilities, and it demystifies those macros a bit. Also, it shows that you can actually avoid them entirely if you want to! (Sycamore also has a builder API that you can use to avoid their view! { .. } macro too, if you really want.)

One more thing to briefly note is about the dist/target_wasm/ and dist/target_engine/ directories. As you might have inferred, the purpose of this is to provide a separate compilation space for Wasm code, which is used under the hood by the CLI whenever it builds your app to Wasm. The reason for this is so that we can build the engine and the browser sides in parallel. With only one target/ directory, Cargo would make us wait until one had completed before starting the other, which slows down compilation. In testing, there tends to be a significant reduction in compilation times as a result of this separation of targets.

Finally, note that the Perseus CLI will automatically install the wasm-bindgen and wasm-opt CLIs in a system-wide cache (see here for how that's calculated), or in dist/tools/ if that fails (there's an option to ensure the local cache is used as well, which you might want to set for more reproducible builds).