diff --git a/lib/kino/bridge.ex b/lib/kino/bridge.ex index eb80d873..e43e1abb 100644 --- a/lib/kino/bridge.ex +++ b/lib/kino/bridge.ex @@ -250,6 +250,15 @@ defmodule Kino.Bridge do match?({:ok, _}, io_request(:livebook_get_evaluation_file)) end + @doc """ + Requests the child spec for proxy handler with the given function. + """ + @spec get_proxy_handler_child_spec((Plug.Conn.t() -> Plug.Conn.t())) :: + {:ok, {module(), term()}} | request_error() + def get_proxy_handler_child_spec(fun) do + io_request({:livebook_get_proxy_handler_child_spec, fun}) + end + defp io_request(request) do gl = Process.group_leader() ref = Process.monitor(gl) diff --git a/lib/kino/proxy.ex b/lib/kino/proxy.ex new file mode 100644 index 00000000..60cf4db4 --- /dev/null +++ b/lib/kino/proxy.ex @@ -0,0 +1,69 @@ +defmodule Kino.Proxy do + @moduledoc """ + Functionality for handling proxy requests forwarded from Livebook. + + Livebook proxies requests at the following paths: + + * `/sessions/:id/proxy/*path` - a notebook session + + * `/apps/:slug/:session_id/proxy/*path` - a specific app session + + * `/apps/:slug/proxy/*path` - generic app path, only supported for + single-session apps. If the app has automatic shutdowns enabled + and it is not currently running, it will be automatically started + + You can define a custom listener to handle requests at these paths. + The listener receives a `Plug.Conn` and it should use the `Plug` API + to send the response, for example: + + Kino.Proxy.listen(fn conn -> + Plug.Conn.send_resp(conn, 200, "hello") + end + + > #### Plug {: .info} + > + > In order to use this feature, you need to add `:plug` as a dependency. + + ## Examples + + Using the proxy feature, we can use Livebook apps to build APIs. + For example, we could provide a data export endpoint: + + data = <<...>> + token = "auth-token" + + Kino.Proxy.listen(fn + %{path_info: ["export", "data"]} = conn -> + ["Bearer " <> ^token] = Plug.Conn.get_req_header(conn, "authorization") + + conn + |> Plug.Conn.put_resp_header("content-type", "application/csv") + |> Plug.Conn.send_resp(200, data) + + conn -> + conn + |> Plug.Conn.put_resp_header("content-type", "application/text") + |> Plug.Conn.send_resp(200, "use /export/data to get extract the report data") + end) + + Once deployed as an app, the user would be able to export the data + by sending a request to `/apps/:slug/proxy/export/data`. + """ + + @doc """ + Registers a request listener. + + Expects the listener to be a function that handles a request + `Plug.Conn`. + """ + @spec listen((Plug.Conn.t() -> Plug.Conn.t())) :: DynamicSupervisor.on_start_child() + def listen(fun) when is_function(fun, 1) do + case Kino.Bridge.get_proxy_handler_child_spec(fun) do + {:ok, child_spec} -> + Kino.start_child(child_spec) + + {:request_error, reason} -> + raise "failed to access the proxy handler child spec, reason: #{inspect(reason)}" + end + end +end diff --git a/mix.exs b/mix.exs index 322f5eec..53e6bcf3 100644 --- a/mix.exs +++ b/mix.exs @@ -35,6 +35,7 @@ defmodule Kino.MixProject do {:table, "~> 0.1.2"}, {:fss, "~> 0.1.0"}, {:nx, "~> 0.1", optional: true}, + {:plug, "~> 1.0", optional: true}, {:ex_doc, "~> 0.28", only: :dev, runtime: false} ] end diff --git a/mix.lock b/mix.lock index 429d0058..d032afb6 100644 --- a/mix.lock +++ b/mix.lock @@ -6,7 +6,11 @@ "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nx": {:hex, :nx, "0.4.0", "2ec2cebec6a9ac8a3d5ae8ef79345cf92f37f9018d50817684e51e97b86f3d36", [:mix], [{:complex, "~> 0.4.2", [hex: :complex, repo: "hexpm", optional: false]}], "hexpm", "bab955768dadfe2208723fbffc9255341b023291f2aabcbd25bf98167dd3399e"}, + "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, }