Build Your First Application
This guide shows one complete browser connection flow. It uses a small Lustre update loop. The bundled Todo and Kanban examples use the same model.
1. Add the packages
Section titled “1. Add the packages”Add each package that the application imports. gleam_json and
strata_protocol are direct dependencies in this flow.
[dependencies]gleam_stdlib = ">= 0.48.0 and < 2.0.0"gleam_json = ">= 3.0.0 and < 4.0.0"lustre = ">= 5.0.0 and < 6.0.0"strata_client = { git = "https://github.com/tylerbutler/strata.git", ref = "<commit>", path = "packages/strata_client" }strata_document = { git = "https://github.com/tylerbutler/strata.git", ref = "<commit>", path = "packages/strata_document" }strata_protocol = { git = "https://github.com/tylerbutler/strata.git", ref = "<commit>", path = "packages/strata_protocol" }strata_transport = { git = "https://github.com/tylerbutler/strata.git", ref = "<commit>", path = "packages/strata_transport" }Pin all Strata packages to the same commit. See Installation for path dependency examples.
2. Define a document field
Section titled “2. Define a document field”Put the following sections in one Gleam module:
import gleam/dynamic.{type Dynamic}import gleam/dynamic/decodeimport gleam/jsonimport gleam/listimport gleam/option.{type Option, None, Some}import gleam/resultimport lustre/effect.{type Effect}import strata_client.{type Connection, type Event}import strata_document.{type Document}import strata_document/schemaimport strata_protocol/dynamic_jsonimport strata_transport
pub type Notes
pub fn title() -> schema.Field(Notes, String) { schema.field("title", json.string, decode.string)}Use a different replica id for each concurrent application instance. The
browser transport provides random_replica_id.
3. Define the connection model
Section titled “3. Define the connection model”Store the socket state and document lifecycle separately. Keep the protocol state, document, and frames that wait for the browser socket queue in the same model:
const document_id: String = "notes"
const reconnect_delay_ms: Int = 2000
pub type Status { Connecting Online Offline}
pub type DocumentStatus { Loading Ready Deleted}
pub type Model { Model( socket: Option(strata_transport.Socket), generation: Int, connection: Connection, document: Document(Notes), replica_id: String, status: Status, document_status: DocumentStatus, outbox: List(String), sending: Bool, last_error: Option(String), )}
pub type Msg { SocketOpened(generation: Int, socket: strata_transport.Socket) SocketText(generation: Int, text: String) SocketClosed(generation: Int) Reconnect FrameQueuedLocally(generation: Int) SendFailed(generation: Int) SetTitle(String)}Connection stores the joined document ids. DocumentStatus prevents edits
before the first snapshot and after deletion. outbox stores each frame until
the browser accepts it into the local WebSocket queue.
4. Initialize and dispatch callbacks
Section titled “4. Initialize and dispatch callbacks”fn init(_flags: Nil) -> #(Model, Effect(Msg)) { let replica_id = "tab-" <> strata_transport.random_replica_id() let #(connection, _join_frame) = strata_client.outbound(strata_client.new(), strata_client.Join(document_id)) let model = Model( socket: None, generation: 0, connection: connection, document: strata_document.new(replica_id), replica_id: replica_id, status: Connecting, document_status: Loading, outbox: [], sending: False, last_error: None, ) #(model, open_websocket(0))}
fn websocket_url() -> String { strata_transport.websocket_url( host: "localhost", port: 8000, path: "/socket/websocket", token: "dev-strata-token", secure: False, )}
fn open_websocket(generation: Int) -> Effect(Msg) { effect.from(fn(dispatch) { let _ = strata_transport.open_socket( websocket_url(), fn(socket) { dispatch(SocketOpened(generation, socket)) }, fn(text) { dispatch(SocketText(generation, text)) }, fn(_) { dispatch(SocketClosed(generation)) }, ) Nil })}Each callback dispatches a message. Use secure: True when the server uses
TLS. Do not use the development token outside local development.
5. Handle socket and application messages
Section titled “5. Handle socket and application messages”The update function keeps every returned Connection value. It also ignores
callbacks from an old connection attempt.
fn update(model: Model, message: Msg) -> #(Model, Effect(Msg)) { case message { SocketOpened(generation, socket) if generation != model.generation -> #( model, effect.from(fn(_) { strata_transport.close(socket) }), ) SocketText(generation, _) | SocketClosed(generation) | FrameQueuedLocally(generation) | SendFailed(generation) if generation != model.generation -> #(model, effect.none())
SocketOpened(_, socket) -> { let #(connection, join_frames) = strata_client.frames_for_resume(model.connection) let model = Model( ..model, socket: Some(socket), connection: connection, status: Online, outbox: prepend_missing(join_frames, model.outbox), ) flush_outbox(model) }
SocketText(_, text) -> case strata_client.inbound(model.connection, text) { Error(_) -> #( Model(..model, last_error: Some("The server sent an invalid frame.")), effect.none(), ) Ok(#(connection, event)) -> apply_event(Model(..model, connection: connection), event) }
SocketClosed(_) -> go_offline(model)
Reconnect -> case model.status { Offline -> { let generation = model.generation + 1 #( Model( ..model, generation: generation, status: Connecting, sending: False, ), open_websocket(generation), ) } Connecting | Online -> #(model, effect.none()) }
FrameQueuedLocally(_) -> flush_outbox( Model(..model, outbox: list.drop(model.outbox, 1), sending: False), )
SendFailed(_) -> go_offline( Model( ..model, socket: None, sending: False, last_error: Some( "The send failed. The application will retry after reconnect.", ), ), )
SetTitle(value) -> case model.document_status { Loading -> #( Model(..model, last_error: Some("Wait for the document to load.")), effect.none(), ) Deleted -> #( Model( ..model, last_error: Some( "This document was deleted. Create a new document to edit.", ), ), effect.none(), ) Ready -> case strata_document.set(model.document, title(), value) { Error(_) -> #( Model(..model, last_error: Some("The title update failed.")), effect.none(), ) Ok(document) -> queue_document( Model(..model, document: document, last_error: None), ) } } }}The application accepts a local edit only when the document is Ready. The
edit updates the document before it creates a frame. The outbox keeps that
frame while the socket is offline.
fn queue_document(model: Model) -> #(Model, Effect(Msg)) { let #(connection, frame) = strata_client.outbound( model.connection, strata_client.SendDelta( document_id, strata_document.to_json(model.document), ), ) flush_outbox( Model( ..model, connection: connection, outbox: list.append(model.outbox, [frame]), ), )}strata_client.outbound updates protocol state before the transport sends the
frame. Keep its returned Connection.
6. Send queued frames
Section titled “6. Send queued frames”Send one frame at a time. Remove a frame after send_text returns Ok(Nil).
This result means that the browser accepted the frame into its local WebSocket
queue. It is not a server acknowledgement. The connection can close before
the server receives the frame.
fn flush_outbox(model: Model) -> #(Model, Effect(Msg)) { case model.status, model.socket, model.sending, model.outbox { Online, Some(socket), False, [frame, ..] -> #( Model(..model, sending: True), effect.from(fn(dispatch) { case strata_transport.send_text(socket, frame) { Ok(Nil) -> dispatch(FrameQueuedLocally(model.generation)) Error(strata_transport.SocketNotOpen) -> dispatch(SendFailed(model.generation)) } }), ) _, _, _, _ -> #(model, effect.none()) }}
fn prepend_missing(frames: List(String), outbox: List(String)) -> List(String) { frames |> list.filter(fn(frame) { !list.contains(outbox, frame) }) |> list.append(outbox)}SendFailed leaves the failed frame at the front of the outbox. The
application retries it after reconnect. A frame that the browser accepted is
no longer in the outbox, so the snapshot recovery step below sends the merged
document state again.
7. Merge inbound document state
Section titled “7. Merge inbound document state”Handle the typed event after inbound returns the new Connection:
fn apply_event(model: Model, event: Event) -> #(Model, Effect(Msg)) { case event { strata_client.Welcomed(..) -> #( Model(..model, last_error: None), effect.none(), ) strata_client.Snapshot(document_id: _, state: state) -> case model.document_status { Deleted -> #(model, effect.none()) Loading | Ready -> case merge_remote(model, state) { Ok(merged) -> queue_document(Model(..merged, document_status: Ready)) Error(_) -> #( Model( ..model, last_error: Some("The remote document state was invalid."), ), effect.none(), ) } } strata_client.RemoteDelta(document_id: _, state: state, from: _) -> case merge_remote(model, state) { Ok(model) -> #(model, effect.none()) Error(_) -> #( Model( ..model, last_error: Some("The remote document state was invalid."), ), effect.none(), ) } strata_client.ServerError(code, message) -> #( Model(..model, last_error: Some(code <> ": " <> message)), effect.none(), ) strata_client.Deleted(document_id: id) -> { let #(connection, frame) = strata_client.outbound(model.connection, strata_client.Leave(id)) flush_outbox( Model( ..model, connection: connection, document_status: Deleted, outbox: list.append(model.outbox, [frame]), last_error: Some("The document was deleted."), ), ) } strata_client.PresenceState(..) | strata_client.PresenceDiff(..) | strata_client.Pong -> #(model, effect.none()) }}
fn merge_remote( model: Model, state: Dynamic,) -> Result(Model, strata_document.DocumentError) { let incoming = dynamic_json.dynamic_to_json(state) case json.to_string(incoming) { "null" -> Ok(model) _ -> strata_document.merge_json(model.document, incoming) |> result.map(fn(document) { Model(..model, document: document, last_error: None) }) }}Keep the current document when merge_json returns an error. Keep the
document status as Loading when the initial snapshot is invalid. A null
snapshot means that the server has no stored state for this id. A valid
null snapshot changes the lifecycle from Loading to Ready.
After each snapshot, including a reconnect snapshot, queue_document creates
a new SendDelta from the merged document. It keeps the Connection returned
by strata_client.outbound. This state resend recovers an edit that the
browser accepted locally but the server did not receive. Strata does not send
a protocol acknowledgement for a delta.
8. Reconnect and resume
Section titled “8. Reconnect and resume”Keep the same Connection when the socket closes. Schedule a new connection
attempt:
fn go_offline(model: Model) -> #(Model, Effect(Msg)) { let reconnect = case model.status { Offline -> effect.none() Connecting | Online -> reconnect_later() } #(Model(..model, socket: None, status: Offline, sending: False), reconnect)}
fn reconnect_later() -> Effect(Msg) { effect.from(fn(dispatch) { set_timeout(fn() { dispatch(Reconnect) }, reconnect_delay_ms) })}
@external(javascript, "./app_ffi.mjs", "set_timeout")fn set_timeout(callback: fn() -> Nil, milliseconds: Int) -> NilAdd the JavaScript helper:
export function set_timeout(callback, milliseconds) { window.setTimeout(callback, milliseconds);}SocketOpened calls frames_for_resume. That function creates a new join
frame for every retained document id. The update function puts those join
frames before queued deltas. The server then sends a snapshot. The snapshot
handler merges remote and local state and queues the merged state as a new
delta.
Send presence again after reconnect. Presence does not survive a disconnect. Use backoff with jitter in a deployed application.
The bundled Todo example contains the full Lustre view and tests. The Kanban example uses the same callback, outbox, merge, and resume flow.
See Browser Transport for socket behavior and Authentication and Connection for the connection lifecycle.