/
live.ex
420 lines (338 loc) · 12 KB
/
live.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
defmodule Kino.JS.Live do
@moduledoc ~S'''
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 `c: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 `c: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 `c: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:
```js
export function init(ctx, data) {
fetch(data.someUrl).then((resp) => {
ctx.handleEvent("update", (payload) => {
// ...
});
});
}
```
Or alternatively:
```js
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
'''
defstruct [:module, :pid, :ref, :export]
alias Kino.JS.Live.Context
@opaque t :: %__MODULE__{
module: module(),
pid: pid(),
ref: Kino.Output.ref(),
export: boolean()
}
@type payload :: term() | {:binary, info :: term(), binary()}
@type from :: GenServer.from()
@doc """
Invoked when the server is started.
See `c:GenServer.init/1` for more details.
> #### Starting other kinos {: .warning}
>
> It is generally not possible to start kinos inside the `c: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 `c: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`.
"""
@callback init(arg :: term(), ctx :: Context.t()) ::
{:ok, ctx :: Context.t()} | {:ok, ctx :: Context.t(), opts :: keyword()}
@doc """
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_connect(ctx :: Context.t()) :: {:ok, payload(), ctx :: Context.t()}
@doc """
Invoked to handle client events.
"""
@callback handle_event(event :: String.t(), payload(), ctx :: Context.t()) ::
{:noreply, ctx :: Context.t()}
@doc """
Invoked to handle asynchronous `cast/2` messages.
See `c:GenServer.handle_cast/2` for more details.
"""
@callback handle_cast(msg :: term(), ctx :: Context.t()) :: {:noreply, ctx :: Context.t()}
@doc """
Invoked to handle synchronous `call/3` messages.
See `c:GenServer.handle_call/3` for more details.
"""
@callback handle_call(msg :: term(), from(), ctx :: Context.t()) ::
{:noreply, ctx :: Context.t()} | {:reply, term(), ctx :: Context.t()}
@doc """
Invoked to handle all other messages.
See `c:GenServer.handle_info/2` for more details.
"""
@callback handle_info(msg :: term(), ctx :: Context.t()) :: {:noreply, ctx :: Context.t()}
@doc """
Invoked when the server is about to exit.
See `c:GenServer.terminate/2` for more details.
"""
@callback terminate(reason, ctx :: Context.t()) :: term()
when reason: :normal | :shutdown | {:shutdown, term} | term
@optional_callbacks init: 2,
handle_event: 3,
handle_call: 3,
handle_cast: 2,
handle_info: 2,
terminate: 2
defmacro __using__(_opts) do
quote location: :keep do
@behaviour Kino.JS.Live
import Kino.JS.Live.Context,
only: [assign: 2, update: 3, broadcast_event: 3, send_event: 4, emit_event: 2]
@before_compile Kino.JS.Live
end
end
def __before_compile__(env) do
unless Module.defines?(env.module, {:__assets_info__, 0}) do
message = """
make sure to include Kino.JS in #{inspect(env.module)} and define the necessary assets.
use Kino.JS
See Kino.JS for more details.
"""
IO.warn(message, Macro.Env.stacktrace(env))
end
nil
end
@doc """
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
* `:export` - a function called to export the given kino to Markdown.
This works the same as `Kino.JS.new/3`, except the function
receives `t:Kino.JS.Live.Context.t/0` as an argument
"""
@spec new(module(), term(), keyword()) :: t()
def new(module, init_arg, opts \\ []) do
export = opts[:export]
ref = Kino.Output.random_ref()
case Kino.start_child({Kino.JS.Live.Server, {module, init_arg, ref, export}}) do
{:ok, pid} ->
subscription_manager = Kino.SubscriptionManager.cross_node_name()
Kino.Bridge.monitor_object(pid, subscription_manager, {:clear_topic, ref})
%__MODULE__{module: module, pid: pid, ref: ref, export: export != nil}
{:error, reason} ->
raise ArgumentError,
"could not start Kino.JS.Live for #{module}, #{Exception.format_exit(reason)}"
end
end
@doc false
@spec output_info(t()) :: map()
def output_info(%__MODULE__{} = kino) do
%{
js_view: %{
ref: kino.ref,
pid: kino.pid,
assets: kino.module.__assets_info__()
},
export: kino.export
}
end
@doc """
Sends an asynchronous request to the kino server.
See `GenServer.cast/2` for more details.
"""
@spec cast(t(), term()) :: :ok
def cast(kino, term) do
Kino.JS.Live.Server.cast(kino.pid, term)
end
@doc """
Makes a synchronous call to the kino server and waits
for its reply.
See `GenServer.call/3` for more details.
"""
@spec call(t(), term(), timeout()) :: term()
def call(kino, term, timeout \\ 5_000) do
Kino.JS.Live.Server.call(kino.pid, term, timeout)
end
@doc """
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.
"""
@spec reply(from(), term()) :: :ok
def reply(kino, term) do
Kino.JS.Live.Server.reply(kino, term)
end
@doc """
Starts monitoring the kino server from the calling process.
Refer to `Process.monitor/1` for more details.
"""
@spec monitor(t()) :: reference()
def monitor(kino) do
Process.monitor(kino.pid)
end
end
defimpl Enumerable, for: Kino.JS.Live do
def reduce(kino, acc, fun), do: Enumerable.reduce(Kino.Control.stream([kino]), acc, fun)
def member?(_kino, _value), do: {:error, __MODULE__}
def count(_kino), do: {:error, __MODULE__}
def slice(_kino), do: {:error, __MODULE__}
end