View Source Kino.JS.Live behaviour (Kino v0.13.1)

Introduces state and event-driven capabilities to JavaScript powered kinos.

Make sure to read the introduction to JavaScript kinos in Kino.JS for more context.

Similarly to static kinos, live kinos involve a custom JavaScript code running in the browser. In fact, this part of the API is the same. In addition, each live kino has a server process running on the Elixir side, responsible for maintaining state and able to communicate with the JavaScript side at any time. Again, to illustrate the ideas we start with a minimal example.

Example

We will follow up on our KinoDocs.HTML example by adding support for replacing the content on demand.

defmodule KinoDocs.LiveHTML do
  use Kino.JS
  use Kino.JS.Live

  def new(html) do
    Kino.JS.Live.new(__MODULE__, html)
  end

  def replace(kino, html) do
    Kino.JS.Live.cast(kino, {:replace, html})
  end

  @impl true
  def init(html, ctx) do
    {:ok, assign(ctx, html: html)}
  end

  @impl true
  def handle_connect(ctx) do
    {:ok, ctx.assigns.html, ctx}
  end

  @impl true
  def handle_cast({:replace, html}, ctx) do
    broadcast_event(ctx, "replace", html)
    {:noreply, assign(ctx, html: html)}
  end

  asset "main.js" do
    """
    export function init(ctx, html) {
      ctx.root.innerHTML = html;

      ctx.handleEvent("replace", (html) => {
        ctx.root.innerHTML = html;
      });
    }
    """
  end
end

Just as before we define a module, this time calling it KinoDocs.LiveHTML for clarity. Note many similarities to the previous version, we still call use Kino.JS, define the main.js file and define the new(html) function for creating a kino instance. As a matter of fact, the initial result of KinoDocs.LiveHTML.new(html) will render exactly the same as our previous KinoDocs.HTML.new(html).

As for the new bits, we added use Kino.JS.Live to define a live kino server. We use Kino.JS.Live.new/2 for creating the kino instance and we implement a few GenServer-like callbacks.

Once the kino server is started with Kino.JS.Live.new/2, the init/2 callback is called with the initial argument. In this case we store the given html in server state.

Whenever the kino is rendered on a new client, the handle_connect/1 callback is called and it builds the initial data for the client. In this case, we always return the stored html. This initial data is then passed to the JavaScript init function. Keep in mind that while the server is initialized once, connect may happen at any point, as the users join/refresh the page.

Finally, the whole point of our example is the ability to replace the HTML content directly from the Elixir side and for this purpose we added the public replace(kino, html) function. Underneath the function uses cast/2 to message our server and the message is handled with handle_cast/2. In this case we store the new html in the server state and broadcast an event with the new value. On the client side, we subscribe to those events with ctx.handleEvent(event, callback) to update the page accordingly.

Event handlers

You must eventually register JavaScript handlers for all events that the client may receive. However, the registration can be deferred, if the initialization is asynchronous. For example, the following is perfectly fine:

export function init(ctx, data) {
  fetch(data.someUrl).then((resp) => {
    ctx.handleEvent("update", (payload) => {
      // ...
    });
  });
}

Or alternatively:

export async function init(ctx, data) {
  const response = await fetch(data.someUrl);

  ctx.handleEvent("update", (payload) => {
    // ...
  });
}

In such case all incoming events are buffered and dispatched once the handler is registered.

Binary payloads

The client-server communication supports binary data, both on initialization and on custom events. On the server side, a binary payload has the form of {:binary, info, binary}, where info is regular JSON-serializable data that can be sent alongside the plain binary.

On the client side, a binary payload is represented as [info, buffer], where info is the additional data and buffer is the binary as ArrayBuffer.

The following example showcases how to send and receive events with binary payloads.

defmodule KinoDocs.Binary do
  use Kino.JS
  use Kino.JS.Live

  def new() do
    Kino.JS.Live.new(__MODULE__, nil)
  end

  @impl true
  def handle_connect(ctx) do
    payload = {:binary, %{message: "hello"}, <<1, 2>>}
    {:ok, payload, ctx}
  end

  @impl true
  def handle_event("ping", {:binary, _info, binary}, ctx) do
    reply_payload = {:binary, %{message: "pong"}, <<1, 2, binary::binary>>}
    broadcast_event(ctx, "pong", reply_payload)
    {:noreply, ctx}
  end

  asset "main.js" do
    """
    export function init(ctx, payload) {
      console.log("initial data", payload);

      ctx.handleEvent("pong", ([info, buffer]) => {
        console.log("event data", [info, buffer])
      });

      const buffer = new ArrayBuffer(2);
      const bytes = new Uint8Array(buffer);
      bytes[0] = 4;
      bytes[1] = 250;
      ctx.pushEvent("ping", [{ message: "ping" }, buffer]);
    }
    """
  end
end

Summary

Callbacks

Invoked to handle synchronous call/3 messages.

Invoked to handle asynchronous cast/2 messages.

Invoked whenever a new client connects to the server.

Invoked to handle client events.

Invoked to handle all other messages.

Invoked when the server is started.

Invoked when the server is about to exit.

Functions

Makes a synchronous call to the kino server and waits for its reply.

Sends an asynchronous request to the kino server.

Starts monitoring the kino server from the calling process.

Instantiates a live JavaScript kino defined by module.

Replies to the kino server.

Types

@type from() :: GenServer.from()
@type payload() :: term() | {:binary, info :: term(), binary()}
@opaque t()

Callbacks

Link to this callback

handle_call(msg, from, ctx)

View Source (optional)
@callback handle_call(msg :: term(), from(), ctx :: Kino.JS.Live.Context.t()) ::
  {:noreply, ctx :: Kino.JS.Live.Context.t()}
  | {:reply, term(), ctx :: Kino.JS.Live.Context.t()}

Invoked to handle synchronous call/3 messages.

See GenServer.handle_call/3 for more details.

Link to this callback

handle_cast(msg, ctx)

View Source (optional)
@callback handle_cast(msg :: term(), ctx :: Kino.JS.Live.Context.t()) ::
  {:noreply, ctx :: Kino.JS.Live.Context.t()}

Invoked to handle asynchronous cast/2 messages.

See GenServer.handle_cast/2 for more details.

@callback handle_connect(ctx :: Kino.JS.Live.Context.t()) ::
  {:ok, payload(), ctx :: Kino.JS.Live.Context.t()}

Invoked whenever a new client connects to the server.

The returned data is passed to the JavaScript init function of the connecting client.

Link to this callback

handle_event(event, payload, ctx)

View Source (optional)
@callback handle_event(event :: String.t(), payload(), ctx :: Kino.JS.Live.Context.t()) ::
  {:noreply, ctx :: Kino.JS.Live.Context.t()}

Invoked to handle client events.

Link to this callback

handle_info(msg, ctx)

View Source (optional)
@callback handle_info(msg :: term(), ctx :: Kino.JS.Live.Context.t()) ::
  {:noreply, ctx :: Kino.JS.Live.Context.t()}

Invoked to handle all other messages.

See GenServer.handle_info/2 for more details.

Link to this callback

init(arg, ctx)

View Source (optional)
@callback init(arg :: term(), ctx :: Kino.JS.Live.Context.t()) ::
  {:ok, ctx :: Kino.JS.Live.Context.t()}
  | {:ok, ctx :: Kino.JS.Live.Context.t(), opts :: keyword()}

Invoked when the server is started.

See GenServer.init/1 for more details.

Starting other kinos

It is generally not possible to start kinos inside the init/2 callback, as such operation would block forever. In case you need to start other kinos during initialization, you must start them beforehand and pass as an argument to init/2. So instead of

defmodule KinoDocs.Custom do
  def new() do
    Kino.JS.Live.new(__MODULE__, {})
  end

  @impl true
  def init({}, ctx) do
    frame = Kino.Frame.new()
    {:ok, assign(ctx, frame: frame)}
  end

  ...
end

do the following

defmodule KinoDocs.Custom do
  def new() do
    frame = Kino.Frame.new()
    Kino.JS.Live.new(__MODULE__, {frame})
  end

  @impl true
  def init({frame}, ctx) do
    {:ok, assign(ctx, frame: frame)}
  end

  ...
end

Also see Kino.start_child/1.

Link to this callback

terminate(reason, ctx)

View Source (optional)
@callback terminate(reason, ctx :: Kino.JS.Live.Context.t()) :: term()
when reason: :normal | :shutdown | {:shutdown, term()} | term()

Invoked when the server is about to exit.

See GenServer.terminate/2 for more details.

Functions

Link to this function

call(kino, term, timeout \\ 5000)

View Source
@spec call(t(), term(), timeout()) :: term()

Makes a synchronous call to the kino server and waits for its reply.

See GenServer.call/3 for more details.

@spec cast(t(), term()) :: :ok

Sends an asynchronous request to the kino server.

See GenServer.cast/2 for more details.

@spec monitor(t()) :: reference()

Starts monitoring the kino server from the calling process.

Refer to Process.monitor/1 for more details.

Link to this function

new(module, init_arg, opts \\ [])

View Source
@spec new(module(), term(), keyword()) :: t()

Instantiates a live JavaScript kino defined by module.

The given init_arg is passed to the init/2 callback when the underlying kino process is started.

Options

@spec reply(from(), term()) :: :ok

Replies to the kino server.

This function can be used to explicitly send a reply to the kino server that called call/3 when the reply cannot be specified in the return value of handle_call/3.

See GenServer.reply/2 for more details.