From 24f6962aa457d32319756f6217aafde7b0a9c752 Mon Sep 17 00:00:00 2001 From: Hayleigh Thompson Date: Tue, 23 Jan 2024 00:09:45 +0000 Subject: =?UTF-8?q?=E2=9C=A8=20Add=20universal=20components=20that=20can?= =?UTF-8?q?=20run=20on=20the=20server=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :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. --- examples/server_demo/src/demo.gleam | 22 ++++++++ examples/server_demo/src/demo/app.gleam | 88 ++++++++++++++++++++++++++++++ examples/server_demo/src/demo/socket.gleam | 78 ++++++++++++++++++++++++++ examples/server_demo/src/demo/web.gleam | 58 ++++++++++++++++++++ 4 files changed, 246 insertions(+) create mode 100644 examples/server_demo/src/demo.gleam create mode 100644 examples/server_demo/src/demo/app.gleam create mode 100644 examples/server_demo/src/demo/socket.gleam create mode 100644 examples/server_demo/src/demo/web.gleam (limited to 'examples/server_demo/src') diff --git a/examples/server_demo/src/demo.gleam b/examples/server_demo/src/demo.gleam new file mode 100644 index 0000000..ebe858a --- /dev/null +++ b/examples/server_demo/src/demo.gleam @@ -0,0 +1,22 @@ +// IMPORTS --------------------------------------------------------------------- + +import demo/socket +import demo/web +import gleam/erlang/process +import mist + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + let assert Ok(_) = + mist.new(fn(req) { + case req.path { + "/ws" -> socket.handle(req) + _ -> web.handle(req) + } + }) + |> mist.port(8000) + |> mist.start_http + + process.sleep_forever() +} diff --git a/examples/server_demo/src/demo/app.gleam b/examples/server_demo/src/demo/app.gleam new file mode 100644 index 0000000..13d09a8 --- /dev/null +++ b/examples/server_demo/src/demo/app.gleam @@ -0,0 +1,88 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/dict.{type Dict} +import gleam/dynamic.{type Decoder} +import gleam/int +import gleam/json +import gleam/result +import lustre/attribute +import lustre/effect.{type Effect} +import lustre/element.{type Element} +import lustre/element/html +import lustre/event +import lustre/server +import lustre/ui + +// MODEL ----------------------------------------------------------------------- + +pub type Model = + Int + +pub fn init(count: Int) -> #(Model, Effect(Msg)) { + #(count, effect.none()) +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + Incr + Decr + Reset(Int) +} + +pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + case msg { + Incr -> #(model + 1, effect.none()) + Decr -> #(model - 1, effect.none()) + Reset(count) -> #( + count, + effect.event( + "changed", + json.string("You reset the count to: " <> int.to_string(count)), + ), + ) + } +} + +pub fn on_attribute_change() -> Dict(String, Decoder(Msg)) { + dict.from_list([ + #("count", fn(dyn) { + dyn + |> dynamic.int + |> result.map(Reset) + }), + ]) +} + +// VIEW ------------------------------------------------------------------------ + +pub fn view(model: Model) -> Element(Msg) { + let count = int.to_string(model) + + ui.centre( + [attribute.style([#("width", "100vw"), #("height", "100vh")])], + ui.sequence([], [ + ui.button([event.on_click(Decr)], [element.text("-")]), + ui.centre([], html.span([], [element.text(count)])), + ui.button([event.on_click(Incr)], [element.text("+")]), + ]), + ) + // ui.cluster([], [ + // ui.input([event.on_input(Change), attribute.value(model.input)]), + // html.span([], [element.text(model.input)]), + // ]), + // ui.centre( + // [ + // event.on("mousemove", on_mouse_move), + // server.include(["offsetX", "offsetY"]), + // attribute.style([ + // #("aspect-ratio", "1 / 1 "), + // #("background-color", "var(--element-background)"), + // ]), + // ], + // html.div([], [ + // html.p([], [element.text("x: " <> int.to_string(model.mouse.0))]), + // html.p([], [element.text("y: " <> int.to_string(model.mouse.1))]), + // ]), + // ), +} diff --git a/examples/server_demo/src/demo/socket.gleam b/examples/server_demo/src/demo/socket.gleam new file mode 100644 index 0000000..bc43962 --- /dev/null +++ b/examples/server_demo/src/demo/socket.gleam @@ -0,0 +1,78 @@ +// IMPORTS --------------------------------------------------------------------- + +import demo/app +import gleam/bit_array +import gleam/erlang/process.{type Subject} +import gleam/function.{identity} +import gleam/http/request.{type Request as HttpRequest} +import gleam/http/response.{type Response as HttpResponse} +import gleam/json +import gleam/option.{Some} +import gleam/otp/actor +import gleam/result +import lustre.{type Action, type ServerComponent} +import lustre/element.{type Patch} +import lustre/server +import mist.{ + type Connection, type ResponseData, type WebsocketConnection, + type WebsocketMessage, +} + +// + +pub fn handle(req: HttpRequest(Connection)) -> HttpResponse(ResponseData) { + mist.websocket(req, update, init, function.constant(Nil)) +} + +type Model(flags, model, msg) { + Model(self: Subject(Patch(msg)), app: Subject(Action(ServerComponent, msg))) +} + +fn init(_) { + let assert Ok(app) = + lustre.component(app.init, app.update, app.view, app.on_attribute_change()) + |> lustre.start_actor(0) + let self = process.new_subject() + let model = Model(self, app) + let selector = process.selecting(process.new_selector(), self, identity) + + actor.send(app, lustre.add_renderer(process.self(), process.send(self, _))) + #(model, Some(selector)) +} + +fn update( + model: Model(flags, model, msg), + conn: WebsocketConnection, + msg: WebsocketMessage(Patch(a)), +) { + case msg { + mist.Text(bits) -> { + let _ = { + use dyn <- json.decode_bits(bits) + use action <- result.try(server.decode_action(dyn)) + + actor.send(model.app, action) + Ok(Nil) + } + + actor.continue(model) + } + mist.Binary(_) -> actor.continue(model) + mist.Closed -> { + actor.send(model.app, lustre.remove_renderer(process.self())) + actor.continue(model) + } + mist.Shutdown -> { + actor.send(model.app, lustre.shutdown()) + actor.Stop(process.Normal) + } + mist.Custom(patch) -> { + element.encode_patch(patch) + |> json.to_string + |> bit_array.from_string + |> mist.send_text_frame(conn, _) + + actor.continue(model) + } + } +} diff --git a/examples/server_demo/src/demo/web.gleam b/examples/server_demo/src/demo/web.gleam new file mode 100644 index 0000000..b8c17e2 --- /dev/null +++ b/examples/server_demo/src/demo/web.gleam @@ -0,0 +1,58 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/http/request.{type Request as HttpRequest} +import gleam/http/response.{type Response as HttpResponse} +import gleam/result +import gleam/string_builder +import lustre/attribute.{attribute} +import lustre/element +import lustre/element/html.{html} +import lustre/server +import lustre/ui/styles +import mist.{type Connection, type ResponseData} +import wisp.{type Request, type Response} + +// + +pub fn handle(req: HttpRequest(Connection)) -> HttpResponse(ResponseData) { + wisp.mist_handler(handler, "")(req) +} + +fn handler(req: Request) -> Response { + use req <- wisp.handle_head(req) + use <- wisp.serve_static(req, under: "/static", from: static_directory()) + + html([attribute("lang", "en")], [ + html.head([], [ + html.meta([attribute("charset", "utf-8")]), + html.meta([ + attribute("name", "viewport"), + attribute("content", "width=device-width, initial-scale=1"), + ]), + styles.elements(), + html.script( + [attribute("type", "module")], + " + import '/static/lustre-server-component.mjs' + + document.addEventListener('DOMContentLoaded', () => { + document + .querySelector('lustre-server-component') + .addEventListener('alert', event => { + console.log(`The server count says: ${event.detail}`) + }) + }) + ", + ), + ]), + html.body([], [server.component([server.route("/ws")])]), + ]) + |> element.to_string_builder + |> string_builder.prepend("\n") + |> wisp.html_response(200) +} + +fn static_directory() -> String { + wisp.priv_directory("lustre") + |> result.unwrap("") +} -- cgit v1.2.3