Bosca / BML Read the docs

Live state & actions

Interactive pages, no client code

An island renders complete HTML on the server and is the region the runtime can update in place afterwards. Most islands never need a client script — declarative actions cover them.

The action loop

An event names a server method

A live state is a serializable model a page provides, reflected by one view island. @click or @submit on any element names a method on it. When the event fires, the runtime posts the state to the server; the server runs the method, persists the new state, re-renders the island, and the runtime swaps the view in place — the same component code as the first render, so the markup stays consistent.

  • Arguments are decoded against the method's own parameter types — Kotlin expressions bind at render time, so loop variables work.
  • form.<field> reads the bound form live at event time; checkboxes become booleans.
  • ctx is supplied server-side, for methods that reach the data plane.
  • The post-action re-render swaps the form too, so it clears itself after a successful submit.
chat-panel.bml
<component tag="chat-panel">
  <prop name="channelId" type="String" required/>
  <prop name="messages" type="List<myapp.MessageRow>" required/>

  <script server provides="panel">
    myapp.ChatPanelModel(channelId, messages)
  </script>

  <island name="chat-view" :key="channelId">
    <for m in panel.messages>
      <chat-msg :message="m"/>
    </for>
    <form @submit="panel.send(form.message, ctx)">
      <input type="text" name="message" required/>
      <button type="submit">Send</button>
    </form>
  </island>
</component>

Models

State that can act

A model method that takes ctx receives the caller's authenticated GraphQL client — so toggling a bookmark or sending a message runs as the person viewing the page, with the same permissions they'd have anywhere else on the platform.

Components can declare their own live state too — one model per instance, so a card inside a <for> loop carries its own toggle without a page-level über-model.

ReaderMarkModel.kt
// @click="mark.toggle(ctx)" runs this on the server
@Serializable
class ReaderMarkModel(val metadataId: String, var markId: Long? = null) {
    val saved: Boolean get() = markId != null

    suspend fun toggle(ctx: RenderContext) {
        val gql = ctx.gql ?: return
        val current = markId
        markId = if (current == null) {
            gql.execute(AddMark, AddMark.Variables(metadataId))
            lookUpNewMarkId(gql, metadataId)
        } else {
            gql.execute(DeleteMark, DeleteMark.Variables(id = current))
            null
        }
    }
}

Privacy & escape hatches

State that stays on the server

By default, live state round-trips through the page and survives a refresh per tab. Adding scope="server" keeps the model in a server-side session instead — the model's JSON never reaches the client, only an opaque HttpOnly session cookie. Use it whenever the state should stay private, like carts and entitlements, or is too large to round-trip.

When a flow doesn't fit the action model, declare a <contract>: a Kotlin interface in the page file. The compiler emits a server dispatcher and a typed TypeScript stub, and client code calls the method as an ordinary typed async function.

a contract and its typed client call
<contract>
  interface GroupChatOps {
      suspend fun updateLastRead(channelId: String, sequence: Long)
  }
</contract>

<script client>
  import { GroupChatOps } from "./GroupChatOps"

  // The generated typed stub — a plain async call.
  GroupChatOps.updateLastRead(channelId, Number(maxSequence))
</script>

Keep exploring

More on BML

Ready for the reference? Read the BML documentation