From fa1f447bd0eaf9d27b6af407f90477b81e70b358 Mon Sep 17 00:00:00 2001 From: Hayleigh Thompson Date: Fri, 5 Apr 2024 13:22:40 +0100 Subject: =?UTF-8?q?=F0=9F=94=80=20Write=20a=20guide=20on=20server-side=20r?= =?UTF-8?q?endering.=20(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :memo: Fix typos in state management guide. * :memo: Begin writing ssr guide. * :memo: Rename discard init arg to '_flags' in all examples. * :memo: Show simple static HTML rendering example. * :memo: Expand hydration section. * :memo: Finish ssr guide. * :wrench: Add ssr guide to built docs pages. --- pages/guide/01-quickstart.md | 6 +- pages/guide/03-side-effects.md | 2 +- pages/guide/04-server-side-rendering.md | 219 ++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 pages/guide/04-server-side-rendering.md (limited to 'pages') diff --git a/pages/guide/01-quickstart.md b/pages/guide/01-quickstart.md index 87e8ace..9a0546f 100644 --- a/pages/guide/01-quickstart.md +++ b/pages/guide/01-quickstart.md @@ -1,4 +1,4 @@ -# 01 Quickstart Guide +# 01 Quickstart guide Welcome to the Lustre quickstart guide! This document should get you up to speed with the core ideas that underpin every Lustre application as well as how to get @@ -163,7 +163,7 @@ model can be an `Int` and our `init` function will initialise it to `0`: ```gleam pub type Model = Int -fn init(_) -> Model { +fn init(_flags) -> Model { 0 } ``` @@ -282,7 +282,7 @@ pub type Model { Model(count: Int, cats: List(String)) } -fn init(_) -> #(Model, effect.Effect(Msg)) { +fn init(_flags) -> #(Model, effect.Effect(Msg)) { #(Model(0, []), effect.none()) } ``` diff --git a/pages/guide/03-side-effects.md b/pages/guide/03-side-effects.md index 5306408..83bf4e7 100644 --- a/pages/guide/03-side-effects.md +++ b/pages/guide/03-side-effects.md @@ -120,7 +120,7 @@ We can, for example, launch an HTTP request on application start by using `lustr in our `init` function: ```gleam -fn init(_) { +fn init(_flags) { let model = Model(...) let get_ip = lustre_http.get( "https://api.ipify.org", diff --git a/pages/guide/04-server-side-rendering.md b/pages/guide/04-server-side-rendering.md new file mode 100644 index 0000000..b72d887 --- /dev/null +++ b/pages/guide/04-server-side-rendering.md @@ -0,0 +1,219 @@ +# 04 Server-side rendering + +Up until now, we have focused on Lustre's ability as a framework for building +Single Page Applications (SPAs). While Lustre's development and feature set is +primarily focused on SPA development, that doesn't mean it can't be used on the +backend as well! In this guide we'll set up a small [mist](https://hexdocs.pm/mist/) +server that renders some static HTML using Lustre. + +## Setting up the project + +We'll start by adding the dependencies we need and scaffolding the HTTP server. +Besides Lustre and Mist, we also need `gleam_erlang` (to keep our application +alive) and `gleam_http` (for types and functions to work with HTTP requests and +responses): + +```sh +gleam new app && cd app && gleam add gleam_erlang gleam_http lustre mist +``` + +Besides imports for `mist` and `gleam_http` modules, we also need to import some +modules to render HTML with Lustre. Importantly, we _don't_ need anything from the +main `lustre` module: we're not building an application with a runtime! + +```gleam +import gleam/bytes_builder +import gleam/http/request.{type Request} +import gleam/http/response.{type Response} +import lustre/element.{type Element} +import lustre/element/html.{html} +import mist.{type Connection, type ResponseData} +``` + +We'll modify Mist's example and write a simple request handler that responds to +requests to `/greet/:name` with a greeting message: + +```gleam +pub fn main() { + let empty_body = mist.Bytes(bytes_builder.new()) + let not_found = response.set_body(response.new(404), empty_body) + + let assert Ok(_) = + fn(req: Request(Connection)) -> Response(ResponseData) { + case request.path_segments(req) { + ["greet", name] -> greet(name) + _ -> not_found + } + } + |> mist.new + |> mist.port(3000) + |> mist.start_http + + process.sleep_forever() +} +``` + +Let's take a peek inside that `greet` function: + +```gleam +fn greet(name: String) -> Response(ResponseData) { + let res = response.new(200) + let html = + html([], [ + html.head([], [html.title([], "Greetings!")]), + html.body([], [ + html.h1([], [html.text("Hey there, " <> name <> "!")]) + ]) + ]) + + response.set_body(res, + html + |> element.to_document_string + |> bytes_builder.from_string + |> mist.Bytes + ) +} +``` + +The `lustre/element` module has functions for rendering Lustre elements to a +string (or string builder); the `to_document_string` function helpfully prepends +the `` declaration to the output. + +It's important to realise that `element.to_string` and `element.to_document_string` +can render _any_ Lustre element! This means you could take the `view` function +from your client-side SPA and render it server-side, too. + +## Hydration + +If we know we can render our apps server-side, the next logical question is how +do we handle _hydration_? Hydration is the process of taking the static HTML +generated by the server and turning it into a fully interactive client application, +ideally doing as little work as possible. + +Most frameworks today support hydration or some equivalent, for example by +serialising the state of each component into the HTML and then picking up where +the server left off. Lustre doesn't have a built-in hydration mechanism, but +because of the way it works, it's easy to implement one yourself! + +We've said many times now that in Lustre, your `view` is just a +[pure function](https://github.com/lustre-labs/lustre/blob/main/pages/hints/pure-functions.md) +of your model. We should produce the same HTMl every time we call `view` with the +same model, no matter how many times we call it. + +Let's use that to our advantage! We know our app's `init` function is responsible +for producing the initial model, so all we need is a way to make sure the initial +model on the client is the same as whate the server used to render the page. + +```gleam +pub fn view(model: Int) -> Element(Msg) { + let count = int.to_string(model) + + html.div([], [ + html.button([event.on_click(Decr)], [html.text("-")]), + html.button([event.on_click(Incr)], [html.text("+")]), + html.p([], [html.text("Count: " <> count)]) + ]) +} +``` + +We've seen the counter example a thousand times over now, but it's a good example +to show off how simple hydration can be. The `view` function produces some HTML +with events attached, but we already know Lustre can render _any_ element to a +string so that shouldn't be a problem. + +Let's imagine our HTTP server responds with the following HTML: + +```gleam +import app/counter +import gleam/bytes_builder +import gleam/http/response.{type Response} +import gleam/json +import lustre/attribute +import lustre/element.{type Element} +import lustre/element/html.{html} +import mist.{type ResponseData} + +fn app() -> Response(ResponseData) { + let res = response.new(200) + + let model = 5 + let html = + html([], [ + html.head([], [ + html.script([attribute.type_("module"), attribute.src("...")], ""), + html.script([attribute.type_("application/json"), attribute.id("model")], + json.int(model) + |> json.to_string + ) + ]), + html.body([], [ + html.div([attribute.id("app")], [ + counter.view(model) + ]) + ]) + ]) + + response.set_body(res, + html + |> element.to_document_string + |> bytes_builder.from_string + |> mist.Bytes + ) +} +``` + +We've rendered the shell of our application, as well as the counter using `5` as +the initial model. Importantly, we've included a `