aboutsummaryrefslogtreecommitdiff
path: root/src/lustre.gleam
diff options
context:
space:
mode:
authorHayleigh Thompson <me@hayleigh.dev>2024-01-23 00:09:45 +0000
committerGitHub <noreply@github.com>2024-01-23 00:09:45 +0000
commit24f6962aa457d32319756f6217aafde7b0a9c752 (patch)
tree42119d9b073f56eabe9dda4ae2065ef4b2086e6a /src/lustre.gleam
parent45e671ac32de95ae1a0a9f9e98da8645d01af3cf (diff)
downloadlustre-24f6962aa457d32319756f6217aafde7b0a9c752.tar.gz
lustre-24f6962aa457d32319756f6217aafde7b0a9c752.zip
✨ Add universal components that can run on the server (#39)
* :heavy_plus_sign: Add gleam_erlang gleam_otp and gleam_json dependencies. * :sparkles: Add json encoders for elememnts and attributes. * :sparkles: Add the ability to perform an effect with a custom dispatch function. * :construction: Experiment with a server-side component runtime. * :construction: Expose special server click events. * :construction: Experiment with a server-side component runtime. * :construction: Experiment with a server-side component runtime. * :construction: Experiment with a server-side component runtime. * :construction: Create a basic server component client bundle. * :construction: Create a basic server component demo. * :bug: Fixed a bug where the runtime stopped performing patches. * :refactor: Roll back introduction of shadow dom. * :recycle: Refactor to Custom Element-based approach to encapsulating server components. * :truck: Move some things around. * :sparkles: Add a minified version of the server component runtime. * :wrench: Add lustre/server/* to internal modules. * :recycle: on_attribute_change and on_client_event handlers are now functions not dicts. * :recycle: Refactor server component event handling to no longer need explicit tags. * :fire: Remove unnecessary attempt to stringify events. * :memo: Start documeint lustre/server functions. * :construction: Experiment with a js implementation of the server component backend runtime. * :recycle: Experiment with an API that makes heavier use of conditional complilation. * :recycle: Big refactor to unify server components, client components, and client apps. * :bug: Fixed some bugs with client runtimes. * :recycle: Update examples to new lustre api/ * :truck: Move server demo into examples/ folder/ * :wrench: Add lustre/runtime to internal modules. * :construction: Experiment with a diffing implementation. * :wrench: Hide internal modules from docs. * :heavy_plus_sign: Update deps to latest versions. * :recycle: Move diffing and vdom code into separate internal modules. * :sparkles: Bring server components to feature parity with client components. * :recycle: Update server component demo. * :bug: Fix bug where attribute changes weren't properly broadcast. * :fire: Remove unused 'Patch' type. * :recycle: Stub out empty js implementations so we can build for js. * :memo: Docs for the docs gods. * :recycle: Rename lustre.server_component to lustre.component.
Diffstat (limited to 'src/lustre.gleam')
-rw-r--r--src/lustre.gleam340
1 files changed, 288 insertions, 52 deletions
diff --git a/src/lustre.gleam b/src/lustre.gleam
index 0c3f2ec..74742e1 100644
--- a/src/lustre.gleam
+++ b/src/lustre.gleam
@@ -1,46 +1,191 @@
+//// Lustre is a framework for rendering Web applications and components using
+//// Gleam. This module contains the core API for constructing and communicating
+//// with the different kinds of Lustre application.
+////
+//// Lustre currently has two kinds of application:
+////
+//// 1. A client-side single-page application: think Elm or React or Vue. These
+//// are applications that run in the client's browser and are responsible for
+//// rendering the entire page.
+////
+//// 2. A client-side component: an encapsulated Lustre application that can be
+//// rendered inside another Lustre application as a Web Component. Communication
+//// happens via attributes and event listeners, like any other encapsulated
+//// HTML element.
+////
+//// 3. A Lustre Server Component. These are applications that run anywhere Gleam
+//// runs and communicate with any number of connected clients by sending them
+//// patches to apply to their DOM.
+////
+//// On the server, these applications can be communicated with by sending them
+//// messages directly. On the client communication happens the same way as
+//// client-side components: through attributes and event listeners.
+////
+//// No matter where a Lustre application runs, it will always follow the same
+//// Model-View-Update architecture. Popularised by Elm (where it is known as The
+//// Elm Architecture), this pattern has since made its way into many other
+//// languages and frameworks and has proven to be a robust and reliable way to
+//// build complex user interfaces.
+////
+//// There are three main building blocks to the Model-View-Update architecture:
+////
+//// - A `Model` that represents your application's state and an `init` function
+//// to create it.
+////
+//// - A `Msg` type that represents all the different ways the outside world can
+//// communicate with your application and an `update` function that modifies
+//// your model in response to those messages.
+////
+//// - A `view` function that renders your model to HTML, represented as an
+//// `Element`.
+////
+//// To see how those pieces fit together, here's a little diagram:
+////
+//// ```text
+//// +--------+
+//// | |
+//// | update |
+//// | |
+//// +--------+
+//// ^ |
+//// | |
+//// Msg | | #(Model, Effect(Msg))
+//// | |
+//// | v
+//// +------+ +------------------------+
+//// | | #(Model, Effect(Msg)) | |
+//// | init |------------------------>| Lustre Runtime |
+//// | | | |
+//// +------+ +------------------------+
+//// ^ |
+//// | |
+//// Msg | | Model
+//// | |
+//// | v
+//// +--------+
+//// | |
+//// | view |
+//// | |
+//// +--------+
+//// ```
+////
+//// ❓ Wondering what that [`Effect`](./effect#effect-type) is all about? Check
+//// out the documentation for that over in the [`effect`](./effect) module.
+////
+//// For many kinds of app, you can take these three building blocks and put
+//// together a Lustre application capable of running *anywhere*. We like to
+//// describe Lustre as a **universal framework**.
+////
//// To read the full documentation for this module, please visit
//// [https://lustre.build/api/lustre](https://lustre.build/api/lustre)
// IMPORTS ---------------------------------------------------------------------
-import gleam/dynamic.{type Decoder}
+import gleam/bool
import gleam/dict.{type Dict}
+import gleam/dynamic.{type Decoder, type Dynamic}
+import gleam/erlang/process.{type Subject}
+import gleam/otp/actor.{type StartError}
+import gleam/result
import lustre/effect.{type Effect}
-import lustre/element.{type Element}
+import lustre/element.{type Element, type Patch}
+import lustre/runtime
// TYPES -----------------------------------------------------------------------
-@target(javascript)
-///
-pub type App(flags, model, msg)
-
-@target(erlang)
-///
+/// Represents a constructed Lustre application that is ready to be started.
+/// Depending on the kind of application you've constructed you have a few
+/// options:
+///
+/// - Use [`start`](#start) to start a single-page-application in the browser.
+///
+/// - Use [`start_server_component`](#start_server_component) to start a Lustre
+/// Server Component anywhere Gleam will run: Erlang, Node, Deno, or in the
+/// browser.
+///
+/// - Use [`start_actor`](#start_actor) to start a Lustre Server Component only
+/// for the Erlang target. BEAM users should always prefer this over
+/// `start_server_component` so they can take advantage of OTP features.
+///
+/// - Use [`register`](#register) to register a component in the browser to be
+/// used as a Custom Element. This is useful even if you're not using Lustre
+/// to build a SPA.
+///
pub opaque type App(flags, model, msg) {
- App
+ App(
+ init: fn(flags) -> #(model, Effect(msg)),
+ update: fn(model, msg) -> #(model, Effect(msg)),
+ view: fn(model) -> Element(msg),
+ on_attribute_change: Dict(String, Decoder(msg)),
+ )
}
+/// The `Browser` runtime is the most typical kind of Lustre application: it's
+/// a single-page application that runs in the browser similar to React or Vue.
+///
+pub type ClientSpa
+
+/// A `ServerComponent` is a type of Lustre application that does not directly
+/// render anything to the DOM. Instead, it can run anywhere Gleam runs and
+/// operates in a "headless" mode where it computes diffs between renders and
+/// sends them to any number of connected listeners.
+///
+/// Lustre Server Components are not tied to any particular transport or network
+/// protocol, but they are most commonly used with WebSockets in a fashion similar
+/// to Phoenix LiveView.
+///
+pub type ServerComponent
+
+/// An action represents a message that can be sent to (some types of) a running
+/// Lustre application. Like the [`App`](#App) type, the `runtime` type parameter
+/// can be used to determine what kinds of application a particular action can be
+/// sent to.
+///
+///
+///
+pub type Action(runtime, msg) =
+ runtime.Action(runtime, msg)
+
+/// Starting a Lustre application might fail for a number of reasons. This error
+/// type enumerates all those reasons, even though some of them are only possible
+/// on certain targets.
+///
+/// This generally makes error handling simpler than having to worry about a bunch
+/// of different error types and potentially unifying them yourself.
+///
pub type Error {
- AppAlreadyStarted
- AppNotYetStarted
+ ActorError(StartError)
BadComponentName
ComponentAlreadyRegistered
ElementNotFound
NotABrowser
+ NotErlang
}
// CONSTRUCTORS ----------------------------------------------------------------
-///
-pub fn element(element: Element(msg)) -> App(Nil, Nil, msg) {
+/// An element is the simplest type of Lustre application. It renders its contents
+/// once and does not handle any messages or effects. Often this type of application
+/// is used for folks just getting started with Lustre on the frontend and want a
+/// quick way to get something on the screen.
+///
+/// Take a look at the [`simple`](#simple) application constructor if you want to
+/// build something interactive.
+///
+/// 💡 Just because an element doesn't have its own update loop, doesn't mean its
+/// content is always static! An element application may render a component or
+/// server component that has its own encapsulated update loop!
+///
+pub fn element(html: Element(msg)) -> App(Nil, Nil, msg) {
let init = fn(_) { #(Nil, effect.none()) }
let update = fn(_, _) { #(Nil, effect.none()) }
- let view = fn(_) { element }
+ let view = fn(_) { html }
application(init, update, view)
}
///
+///
pub fn simple(
init: fn(flags) -> model,
update: fn(model, msg) -> model,
@@ -53,67 +198,158 @@ pub fn simple(
}
///
-@external(javascript, "./lustre.ffi.mjs", "setup")
+///
pub fn application(
- _init: fn(flags) -> #(model, Effect(msg)),
- _update: fn(model, msg) -> #(model, Effect(msg)),
- _view: fn(model) -> Element(msg),
+ init: fn(flags) -> #(model, Effect(msg)),
+ update: fn(model, msg) -> #(model, Effect(msg)),
+ view: fn(model) -> Element(msg),
) -> App(flags, model, msg) {
- // Applications are not usable on the erlang target. For those users, `App`
- // is an opaque type (aka they can't see its structure) and functions like
- // `start` and `destroy` are no-ops.
- //
- // Because the constructor is marked as `@target(erlang)` for some reason we
- // can't simply refer to it here even though the compiler should know that the
- // body of this function can only be entered from erlang (because we have an
- // external def for javascript) but alas, it does not.
- //
- // So instead, we must do this awful hack and cast a `Nil` to the `App` type
- // to make everything happy. Theoeretically this is not going to be a problem
- // unless someone starts poking around with their own ffi and at that point
- // they deserve it.
- dynamic.unsafe_coerce(dynamic.from(Nil))
-}
-
-@external(javascript, "./lustre.ffi.mjs", "setup_component")
+ App(init, update, view, dict.new())
+}
+
+///
+///
pub fn component(
- _name: String,
- _init: fn() -> #(model, Effect(msg)),
- _update: fn(model, msg) -> #(model, Effect(msg)),
- _view: fn(model) -> Element(msg),
- _on_attribute_change: Dict(String, Decoder(msg)),
-) -> Result(Nil, Error) {
- Ok(Nil)
+ init: fn(flags) -> #(model, Effect(msg)),
+ update: fn(model, msg) -> #(model, Effect(msg)),
+ view: fn(model) -> Element(msg),
+ on_attribute_change: Dict(String, Decoder(msg)),
+) -> App(flags, model, msg) {
+ App(init, update, view, on_attribute_change)
}
// EFFECTS ---------------------------------------------------------------------
///
-@external(javascript, "./lustre.ffi.mjs", "start")
+///
pub fn start(
+ app: App(flags, model, msg),
+ onto selector: String,
+ with flags: flags,
+) -> Result(fn(Action(ClientSpa, msg)) -> Nil, Error) {
+ use <- bool.guard(!is_browser(), Error(NotABrowser))
+ do_start(app, selector, flags)
+}
+
+@external(javascript, "./client-runtime.ffi.mjs", "start")
+fn do_start(
_app: App(flags, model, msg),
_selector: String,
_flags: flags,
-) -> Result(fn(msg) -> Nil, Error) {
+) -> Result(fn(Action(ClientSpa, msg)) -> Nil, Error) {
+ // It should never be possible for the body of this function to execute on the
+ // Erlang target because the `is_browser` guard will prevent it. Instead of
+ // a panic, we still return a well-typed `Error` here in the case where someone
+ // mistakenly uses this function internally.
Error(NotABrowser)
}
///
-@external(javascript, "./lustre.ffi.mjs", "destroy")
-pub fn destroy(_app: App(flags, model, msg)) -> Result(Nil, Error) {
- Ok(Nil)
+///
+@external(javascript, "./server-runtime.ffi.mjs", "start")
+pub fn start_server_component(
+ app: App(flags, model, msg),
+ with flags: flags,
+) -> Result(fn(Action(ServerComponent, msg)) -> Nil, Error) {
+ use runtime <- result.map(start_actor(app, flags))
+ actor.send(runtime, _)
+}
+
+///
+///
+/// 🚨 This function is only meaningful on the Erlang target. Attempts to call
+/// it on the JavaScript will result in the `NotErlang` error. If you're running
+/// a Lustre Server Component on Node or Deno, use
+/// [`start_server_component`](#start_server_component) instead.
+///
+pub fn start_actor(
+ app: App(flags, model, msg),
+ with flags: flags,
+) -> Result(Subject(Action(ServerComponent, msg)), Error) {
+ do_start_actor(app, flags)
+}
+
+@target(javascript)
+fn do_start_actor(_, _) {
+ Error(NotErlang)
+}
+
+@target(erlang)
+fn do_start_actor(
+ app: App(flags, model, msg),
+ flags: flags,
+) -> Result(Subject(Action(ServerComponent, msg)), Error) {
+ app.init(flags)
+ |> runtime.start(app.update, app.view, app.on_attribute_change)
+ |> result.map_error(ActorError)
+}
+
+/// Register a Lustre application as a Web Component. This lets you render that
+/// application in another Lustre application's view or use it as a Custom Element
+/// outside of Lustre entirely.
+///
+/// 💡 The provided application can only have `Nil` flags, because there is no way
+/// to specify flags when the component is first rendered.
+///
+/// 💡 There are [some rules](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define#valid_custom_element_names)
+/// for what names are valid for a Custom Element. The most important one is that
+/// the name *must* contain a hypen so that it can be distinguished from standard
+/// HTML elements.
+///
+/// 🚨 This function is only meaningful when running in the browser. For server
+/// contexts, you can render a Lustre Server Component using `start_server_component`
+/// or `start_actor` instead.
+///
+@external(javascript, "./client-component.ffi.mjs", "register")
+pub fn register(app: App(Nil, model, msg), name: String) -> Result(Nil, Error) {
+ Error(NotABrowser)
+}
+
+// ACTIONS ---------------------------------------------------------------------
+
+pub fn add_renderer(
+ id: any,
+ renderer: fn(Patch(msg)) -> Nil,
+) -> Action(ServerComponent, msg) {
+ runtime.AddRenderer(dynamic.from(id), renderer)
+}
+
+pub fn dispatch(msg: msg) -> Action(runtime, msg) {
+ runtime.Dispatch(msg)
+}
+
+pub fn event(name: String, data: Dynamic) -> Action(ServerComponent, msg) {
+ runtime.Event(name, data)
+}
+
+pub fn remove_renderer(id: any) -> Action(ServerComponent, msg) {
+ runtime.RemoveRenderer(dynamic.from(id))
+}
+
+pub fn shutdown() -> Action(runtime, msg) {
+ runtime.Shutdown
}
// UTILS -----------------------------------------------------------------------
-///
-@external(javascript, "./lustre.ffi.mjs", "is_browser")
+/// Gleam's conditional compilation makes it possible to have different implementations
+/// of a function for different targets, but it's not possible to know what runtime
+/// you're targetting at compile-time.
+///
+/// This is problematic if you're using Lustre Server Components with a JavaScript
+/// backend because you'll want to know whether you're currently running on your
+/// server or in the browser: this function tells you that!
+///
+@external(javascript, "./client-runtime.ffi.mjs", "is_browser")
pub fn is_browser() -> Bool {
False
}
-///
-@external(javascript, "./lustre.ffi.mjs", "is_registered")
-pub fn is_registered(_name: String) -> Bool {
+/// Check if the given component name has already been registered as a Custom
+/// Element. This is particularly useful in contexts where _other web components_
+/// may have been registered and you must avoid collisions.
+///
+@external(javascript, "./client-runtime.ffi.mjs", "is_registered")
+pub fn is_registered(name: String) -> Bool {
False
}