Skip to content
Strata is pre-1.0. APIs, protocol, and storage format may change.

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.

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.

Put the following sections in one Gleam module:

import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import lustre/effect.{type Effect}
import strata_client.{type Connection, type Event}
import strata_document.{type Document}
import strata_document/schema
import strata_protocol/dynamic_json
import 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.

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.

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.

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.

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.

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.

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) -> Nil

Add 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.