View Source Kino.JS.Live behaviour (Kino v0.7.0)
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
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
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
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
Link to this section 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.
Instantiates a live JavaScript kino defined by module
.
Link to this section Types
@opaque t()
Link to this section Callbacks
@callback handle_call(msg :: term(), from :: term(), 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.
@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.
@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.
@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.
@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 toinit/2
. So instead ofdefmodule 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
.
@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.
Link to this section Functions
Makes a synchronous call to the kino server and waits for its reply.
See GenServer.call/3
for more details.
Sends an asynchronous request to the kino server.
See GenServer.cast/2
for more details.
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.