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/04-server-side-rendering.md | 219 ++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 pages/guide/04-server-side-rendering.md (limited to 'pages/guide/04-server-side-rendering.md') 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 `