Current section

Files

Jump to
omnimessage_server src omnimessage server.gleam
Raw

src/omnimessage/server.gleam

/// omnimessage/server is the collection of tools allowing you to handle
/// connections from omnimessage/lustre applications.
///
/// The rule of thumb is if it initiates the connection, it's a client. If it
/// responds to a connection request, it's a server.
///
/// While you could do this manually fairly simples, theese tools can help you
/// get started quicker and provide a nicer quality-of-life.
///
/// Do read the source of the functions to understand how to make your own
/// customized solution.
///
/// Currently only the erlang target is supported, but you could easily adapt
/// the principles to the Node, Deno, or Bun targets. Or even to a runtime
/// outside Gleam's ecosystem -- as long as you can send and receive encoded
/// messages, you can communicate with omnimessage/lustre.
///
import gleam/erlang/process.{type Subject}
import gleam/function
import gleam/http
import gleam/http/request
import gleam/option.{type Option, None, Some}
import gleam/otp/actor
import gleam/result
import lustre/component
import lustre/server_component
import lustre
import lustre/effect.{type Effect}
import lustre/element
import mist
import wisp
/// Holds decode and encode functions for omnimessage messages. Decode errors
/// will be called back for you to handle, while Encode errors are interpreted
/// as "skip this message" -- no error will be raised for them and they won't
/// be sent over.
///
/// Since an `EncoderDecoder` is expected to receive the whole message type of
/// an application, but usually will ignore messages that aren't shared, it's
/// best to define it as a thin wrapper around shared encoders/decoders:
///
/// ```gleam
/// // Holds shared message types, encoders and decoders
/// import shared
///
/// let encoder_decoder =
/// EncoderDecoder(
/// fn(msg) {
/// case msg {
/// // Messages must be encodable
/// ClientMessage(message) -> Ok(shared.encode_client_message(message))
/// // Return Error(Nil) for messages you don't want to send out
/// _ -> Error(Nil)
/// }
/// },
/// fn(encoded_msg) {
/// // Unsupported messages will cause TransportError(DecodeError(error))
/// shared.decode_server_message(encoded_msg)
/// |> result.map(ServerMessage)
/// },
/// )
/// ```
///
pub type EncoderDecoder(msg, encoding, decode_error) {
EncoderDecoder(
encode: fn(msg) -> Result(encoding, Nil),
decode: fn(encoding) -> Result(msg, decode_error),
)
}
/// A utility function for easily handling messages:
///
/// ```gleam
/// let out_msg = pipe(in_msg, encoder_decoder, handler)
/// ```
///
pub fn pipe(
msg: encoding,
encoder_decoder: EncoderDecoder(msg, encoding, decode_error),
handler: fn(msg) -> msg,
) -> Result(Option(encoding), decode_error) {
msg
|> encoder_decoder.decode
|> result.map(handler)
|> result.map(encoder_decoder.encode)
// Encoding error means "skip this message"
|> result.map(option.from_result)
}
///
pub opaque type App(start_args, model, msg, encoding, decode_error) {
App(
init: fn(start_args) -> #(model, Effect(msg)),
update: fn(model, msg) -> #(model, Effect(msg)),
options: Option(List(component.Option(msg))),
encoder_decoder: EncoderDecoder(msg, encoding, decode_error),
)
}
/// This creates a version of a Lustre application that can be used in
/// `omnimessage/server.start_actor` (see below). A view is not necessary, as
/// this application will never render anything.
///
pub fn application(
init init: fn(start_args) -> #(model, Effect(msg)),
update update: fn(model, msg) -> #(model, Effect(msg)),
encoder_decoder encoder_decoder: EncoderDecoder(msg, encoding, decode_error),
) -> App(start_args, model, msg, encoding, decode_error) {
App(init: init, update: update, options: None, encoder_decoder:)
}
pub fn component(
init init: fn(start_args) -> #(model, Effect(msg)),
update update: fn(model, msg) -> #(model, Effect(msg)),
options options: List(component.Option(msg)),
encoder_decoder encoder_decoder: EncoderDecoder(msg, encoding, decode_error),
) -> App(start_args, model, msg, encoding, decode_error) {
App(init: init, update: update, options: Some(options), encoder_decoder:)
}
/// This is a beefed up version of `lustre.start_actor` that allows subscribing
/// to messages dispatched inside the runtime.
///
/// This is what enables using a Lustre server component for communication,
/// powering `mist_websocket_application()` below.
///
pub fn start_server_component(
app: App(start_args, model, msg, encoding, decode_error),
with_args start_args: start_args,
with_listener listener: fn(msg) -> Nil,
) -> Result(lustre.Runtime(msg), lustre.Error) {
let wrapped_update = fn(model, msg) {
listener(msg)
app.update(model, msg)
}
let view = fn(_model) { element.none() }
let lustre_app = case app.options {
None -> lustre.application(app.init, wrapped_update, view)
Some(options) -> lustre.component(app.init, wrapped_update, view, options)
}
lustre.start_server_component(lustre_app, with: start_args)
}
/// A wisp middleware to automatically handle HTTP POST omnimessage messages.
///
/// - `req` The wisp request
/// - `path` The path to which messages are POSTed
/// - `encoder_decoder` For encoding and decoding messages
/// - `handler` For handling the incoming messages
///
/// See a full example using this in the Readme or in the examples folder.
///
pub fn wisp_http_middleware(
req: wisp.Request,
path: String,
encoder_decoder,
handler,
fun: fn() -> wisp.Response,
) -> wisp.Response {
case req.path == path, req.method {
True, http.Post -> {
use req_body <- wisp.require_string_body(req)
case
req_body
|> pipe(encoder_decoder, handler)
{
Ok(Some(res_body)) -> wisp.response(200) |> wisp.string_body(res_body)
Ok(None) -> wisp.response(200)
Error(_) -> wisp.unprocessable_entity()
}
}
_, _ -> fun()
}
}
/// A mist websocket handler to automatically respond to omnimessage messages.
///
/// Return this as a response to the websocket init request.
///
/// - `req` The mist request
/// - `encoder_decoder` For encoding and decoding messages
/// - `handler` For handling the incoming messages
/// - `on_error` For handling decode errors
///
/// See a full example using this in the Readme or in the examples folder.
///
pub fn mist_websocket_pipe(
req: request.Request(mist.Connection),
encoder_decoder: EncoderDecoder(msg, String, decode_error),
handler: fn(msg) -> msg,
on_error: fn(decode_error) -> Nil,
) {
mist.websocket(
request: req,
on_init: fn(_conn) { #(None, None) },
handler: fn(runtime, conn, msg) {
case msg {
mist.Text(msg) -> {
let _ = case pipe(msg, encoder_decoder, handler) {
Ok(Some(encoded_msg)) -> mist.send_text_frame(conn, encoded_msg)
Ok(None) -> Ok(Nil)
Error(decode_error) -> Ok(on_error(decode_error))
}
actor.continue(runtime)
}
mist.Binary(_) -> actor.continue(runtime)
mist.Custom(_) -> actor.continue(runtime)
mist.Closed | mist.Shutdown -> actor.Stop(process.Normal)
}
},
on_close: fn(_) { Nil },
)
}
type WebsocketState(msg) {
WebsocketState(
runtime: lustre.Runtime(msg),
omni_self: Subject(msg),
lustre_self: Subject(server_component.ClientMessage(msg)),
)
}
/// A mist websocket handler to automatically respond to omnimessage messages
/// via a Lustre server component. The server component can then be used
/// similarly to one created by an `omnimessage/lustre` and handle the messages
/// via update, dispatch, and effects.
///
/// Return this as a response to the websocket init request.
///
/// - `req` The mist request
/// - `app` An application created with `omnimessage/server.application`
/// - `flags` Flags to hand to the application's `init`
/// - `on_error` For handling decode errors
///
/// See a full example using this in the Readme or in the examples folder.
///
pub fn mist_websocket_application(
req: request.Request(mist.Connection),
app: App(flags, model, msg, String, decode_error),
flags: flags,
on_error: fn(decode_error) -> Nil,
) {
mist.websocket(
request: req,
on_init: fn(_conn) {
let omni_self = process.new_subject()
let lustre_self = process.new_subject()
let assert Ok(runtime) =
start_server_component(app, flags, process.send(omni_self, _))
let state = WebsocketState(runtime:, omni_self:, lustre_self:)
#(
state,
option.Some(
process.new_selector()
|> process.selecting(omni_self, function.identity),
),
)
},
handler: fn(state: WebsocketState(msg), conn, msg) {
case msg {
mist.Text(msg) -> {
case app.encoder_decoder.decode(msg) {
Ok(decoded_msg) ->
lustre.send(state.runtime, lustre.dispatch(decoded_msg))
Error(decode_error) -> on_error(decode_error)
}
actor.continue(state)
}
mist.Binary(_) -> actor.continue(state)
mist.Custom(msg) -> {
// TODO: do we really want to crash this?
let assert Ok(_) = case app.encoder_decoder.encode(msg) {
Ok(msg) -> mist.send_text_frame(conn, msg)
// Encode error is interpreted as "skip this message"
Error(_) -> Ok(Nil)
}
actor.continue(state)
}
mist.Closed | mist.Shutdown -> {
server_component.deregister_subject(state.lustre_self)
|> lustre.send(to: state.runtime)
actor.Stop(process.Normal)
}
}
},
on_close: fn(state) {
server_component.deregister_subject(state.lustre_self)
|> lustre.send(to: state.runtime)
},
)
}