mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-12-17 21:50:25 +08:00
Serve iframes from another local port when running on http (#989)
* Serve iframes from another local port when running on http * Use relative hostname in local iframe URL * Simplify server start check * Use random iframe port when Livebook runs on a random port * Rename space/ to iframe/ * LivebookWeb.IframePlug -> LivebookWeb.IframeEndpoint
This commit is contained in:
parent
93592c1f89
commit
e5e13d86c2
30 changed files with 835 additions and 58 deletions
|
|
@ -26,6 +26,7 @@ RUN mix do deps.get, deps.compile
|
||||||
# Compile and build the release
|
# Compile and build the release
|
||||||
COPY rel rel
|
COPY rel rel
|
||||||
COPY static static
|
COPY static static
|
||||||
|
COPY iframe/priv/static/iframe iframe/priv/static/iframe
|
||||||
COPY lib lib
|
COPY lib lib
|
||||||
# We need README.md during compilation
|
# We need README.md during compilation
|
||||||
# (look for @external_resource "README.md")
|
# (look for @external_resource "README.md")
|
||||||
|
|
|
||||||
|
|
@ -93,17 +93,17 @@ and also for local usage in case you don't have Elixir installed.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
# Running with the default configuration
|
# Running with the default configuration
|
||||||
docker run -p 8080:8080 --pull always livebook/livebook
|
docker run -p 8080:8080 -p 8081:8081 --pull always livebook/livebook
|
||||||
|
|
||||||
# In order to access and save notebooks directly to your machine
|
# In order to access and save notebooks directly to your machine
|
||||||
# you can mount a local directory into the container.
|
# you can mount a local directory into the container.
|
||||||
# Make sure to specify the user with "-u $(id -u):$(id -g)"
|
# Make sure to specify the user with "-u $(id -u):$(id -g)"
|
||||||
# so that the created files have proper permissions
|
# so that the created files have proper permissions
|
||||||
docker run -p 8080:8080 --pull always -u $(id -u):$(id -g) -v $(pwd):/data livebook/livebook
|
docker run -p 8080:8080 -p 8081:8081 --pull always -u $(id -u):$(id -g) -v $(pwd):/data livebook/livebook
|
||||||
|
|
||||||
# You can configure Livebook using environment variables,
|
# You can configure Livebook using environment variables,
|
||||||
# for all options see the dedicated "Environment variables" section below
|
# for all options see the dedicated "Environment variables" section below
|
||||||
docker run -p 8080:8080 --pull always -e LIVEBOOK_PASSWORD="securesecret" livebook/livebook
|
docker run -p 8080:8080 -p 8081:8081 --pull always -e LIVEBOOK_PASSWORD="securesecret" livebook/livebook
|
||||||
```
|
```
|
||||||
|
|
||||||
To try out features from the main branch you can alternatively
|
To try out features from the main branch you can alternatively
|
||||||
|
|
@ -160,6 +160,9 @@ The following environment variables configure Livebook:
|
||||||
default path used on file selection screens and others. Defaults to the
|
default path used on file selection screens and others. Defaults to the
|
||||||
user's operating system home.
|
user's operating system home.
|
||||||
|
|
||||||
|
* LIVEBOOK_IFRAME_PORT - sets the port that Livebook serves iframes at.
|
||||||
|
This is relevant only when running Livebook without TLS. Defaults to 8081.
|
||||||
|
|
||||||
* LIVEBOOK_IP - sets the ip address to start the web application on.
|
* LIVEBOOK_IP - sets the ip address to start the web application on.
|
||||||
Must be a valid IPv4 or IPv6 address.
|
Must be a valid IPv4 or IPv6 address.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Socket } from "phoenix";
|
import { Socket } from "phoenix";
|
||||||
import { getAttributeOrThrow } from "../lib/attribute";
|
import { getAttributeOrThrow, parseInteger } from "../lib/attribute";
|
||||||
import { randomToken, sha256Base64 } from "../lib/utils";
|
import { randomToken, sha256Base64 } from "../lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -29,6 +29,11 @@ import { randomToken, sha256Base64 } from "../lib/utils";
|
||||||
* * `data-session-token` - token is sent in the "connect" message to
|
* * `data-session-token` - token is sent in the "connect" message to
|
||||||
* the channel
|
* the channel
|
||||||
*
|
*
|
||||||
|
* * `data-session-id` - the identifier of the session that this output
|
||||||
|
* belongs go
|
||||||
|
*
|
||||||
|
* * `data-iframe-local-port` - the local port where the iframe is served
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
const JSOutput = {
|
const JSOutput = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
@ -135,7 +140,7 @@ const JSOutput = {
|
||||||
|
|
||||||
// Load the iframe content
|
// Load the iframe content
|
||||||
const iframesEl = document.querySelector(`[data-element="output-iframes"]`);
|
const iframesEl = document.querySelector(`[data-element="output-iframes"]`);
|
||||||
initializeIframeSource(iframe).then(() => {
|
initializeIframeSource(iframe, this.props.iframePort).then(() => {
|
||||||
iframesEl.appendChild(iframe);
|
iframesEl.appendChild(iframe);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -156,8 +161,11 @@ const JSOutput = {
|
||||||
|
|
||||||
const eventRef = channel.on(`event:${this.props.ref}`, (raw) => {
|
const eventRef = channel.on(`event:${this.props.ref}`, (raw) => {
|
||||||
const [[event], payload] = transportDecode(raw);
|
const [[event], payload] = transportDecode(raw);
|
||||||
|
|
||||||
|
this.state.childReadyPromise.then(() => {
|
||||||
postMessage({ type: "event", event, payload });
|
postMessage({ type: "event", event, payload });
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const errorRef = channel.on(`error:${this.props.ref}`, ({ message }) => {
|
const errorRef = channel.on(`error:${this.props.ref}`, ({ message }) => {
|
||||||
if (!this.state.errorContainer) {
|
if (!this.state.errorContainer) {
|
||||||
|
|
@ -201,6 +209,11 @@ function getProps(hook) {
|
||||||
jsPath: getAttributeOrThrow(hook.el, "data-js-path"),
|
jsPath: getAttributeOrThrow(hook.el, "data-js-path"),
|
||||||
sessionToken: getAttributeOrThrow(hook.el, "data-session-token"),
|
sessionToken: getAttributeOrThrow(hook.el, "data-session-token"),
|
||||||
sessionId: getAttributeOrThrow(hook.el, "data-session-id"),
|
sessionId: getAttributeOrThrow(hook.el, "data-session-id"),
|
||||||
|
iframePort: getAttributeOrThrow(
|
||||||
|
hook.el,
|
||||||
|
"data-iframe-local-port",
|
||||||
|
parseInteger
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,34 +297,48 @@ function bindIframeSize(iframe, iframePlaceholder) {
|
||||||
// would be insecure (2). Consequently, we need to load the iframe
|
// would be insecure (2). Consequently, we need to load the iframe
|
||||||
// from a different origin.
|
// from a different origin.
|
||||||
//
|
//
|
||||||
|
// When running Livebook on https:// we load the iframe from another
|
||||||
|
// https:// origin. On the other hand, when running on http:// we want
|
||||||
|
// to load the iframe from http:// as well, otherwise the browser could
|
||||||
|
// block asset requests from the https:// iframe to http:// Livebook.
|
||||||
|
// However, external http:// content is not considered a secure context (3),
|
||||||
|
// which implies no access to user media. Therefore, instead of using
|
||||||
|
// http://livebook.space we use another localhost endpoint. Note that
|
||||||
|
// this endpoint has a different port than the Livebook web app, that's
|
||||||
|
// because we need separate origins, as outlined above.
|
||||||
|
//
|
||||||
// To ensure integrity of the loaded content we manually verify the
|
// To ensure integrity of the loaded content we manually verify the
|
||||||
// checksum against the expected value.
|
// checksum against the expected value.
|
||||||
//
|
//
|
||||||
// (1): https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#document_source_security
|
// (1): https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#document_source_security
|
||||||
// (2): https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
|
// (2): https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
|
||||||
|
// (3): https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
|
||||||
|
|
||||||
// When running Livebook on http:// we want to load the iframe from
|
|
||||||
// http:// as well, otherwise the browser could block asset requests
|
|
||||||
// from the https:// iframe to http:// Livebook. Both protocols are
|
|
||||||
// supported by livebook.space
|
|
||||||
const IFRAME_URL = `${window.location.protocol}//livebook.space/iframe/v2.html`;
|
|
||||||
const IFRAME_SHA256 = "+uJyGu0Ey7uVV7WwRwg7GyjwCkMNRBnyNc25iGFpYXc=";
|
const IFRAME_SHA256 = "+uJyGu0Ey7uVV7WwRwg7GyjwCkMNRBnyNc25iGFpYXc=";
|
||||||
|
|
||||||
function initializeIframeSource(iframe) {
|
function getIframeUrl(iframePort) {
|
||||||
return verifyIframeSource().then(() => {
|
return window.location.protocol === "https:"
|
||||||
|
? "https://livebook.space/iframe/v2.html"
|
||||||
|
: `http://${window.location.hostname}:${iframePort}/iframe/v2.html`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeIframeSource(iframe, iframePort) {
|
||||||
|
const iframeUrl = getIframeUrl(iframePort);
|
||||||
|
|
||||||
|
return verifyIframeSource(iframeUrl).then(() => {
|
||||||
iframe.sandbox =
|
iframe.sandbox =
|
||||||
"allow-scripts allow-same-origin allow-downloads allow-modals";
|
"allow-scripts allow-same-origin allow-downloads allow-modals";
|
||||||
iframe.allow =
|
iframe.allow =
|
||||||
"accelerometer; ambient-light-sensor; camera; display-capture; encrypted-media; geolocation; gyroscope; microphone; midi; usb; xr-spatial-tracking";
|
"accelerometer; ambient-light-sensor; camera; display-capture; encrypted-media; geolocation; gyroscope; microphone; midi; usb; xr-spatial-tracking";
|
||||||
iframe.src = IFRAME_URL;
|
iframe.src = iframeUrl;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let iframeVerificationPromise = null;
|
let iframeVerificationPromise = null;
|
||||||
|
|
||||||
function verifyIframeSource() {
|
function verifyIframeSource(iframeUrl) {
|
||||||
if (!iframeVerificationPromise) {
|
if (!iframeVerificationPromise) {
|
||||||
iframeVerificationPromise = fetch(IFRAME_URL)
|
iframeVerificationPromise = fetch(iframeUrl)
|
||||||
.then((response) => response.text())
|
.then((response) => response.text())
|
||||||
.then((html) => {
|
.then((html) => {
|
||||||
if (sha256Base64(html) !== IFRAME_SHA256) {
|
if (sha256Base64(html) !== IFRAME_SHA256) {
|
||||||
|
|
@ -325,6 +352,8 @@ function verifyIframeSource() {
|
||||||
return iframeVerificationPromise;
|
return iframeVerificationPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encoding/decoding of channel payloads
|
||||||
|
|
||||||
function transportEncode(meta, payload) {
|
function transportEncode(meta, payload) {
|
||||||
if (
|
if (
|
||||||
Array.isArray(payload) &&
|
Array.isArray(payload) &&
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ config :livebook, LivebookWeb.Endpoint,
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
config :livebook, :iframe_port, 4001
|
||||||
|
|
||||||
# ## SSL Support
|
# ## SSL Support
|
||||||
#
|
#
|
||||||
# In order to use HTTPS in development, a self-signed
|
# In order to use HTTPS in development, a self-signed
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ config :livebook, LivebookWeb.Endpoint,
|
||||||
http: [ip: {127, 0, 0, 1}, port: 8080],
|
http: [ip: {127, 0, 0, 1}, port: 8080],
|
||||||
server: true
|
server: true
|
||||||
|
|
||||||
|
config :livebook, :iframe_port, 8081
|
||||||
|
|
||||||
# Set log level to warning by default to reduce output
|
# Set log level to warning by default to reduce output
|
||||||
config :logger, level: :warning
|
config :logger, level: :warning
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ config :livebook, LivebookWeb.Endpoint,
|
||||||
http: [port: 4002],
|
http: [port: 4002],
|
||||||
server: false
|
server: false
|
||||||
|
|
||||||
|
config :livebook, :iframe_port, 4003
|
||||||
|
|
||||||
# Print only warnings and errors during test
|
# Print only warnings and errors during test
|
||||||
config :logger, level: :warn
|
config :logger, level: :warn
|
||||||
|
|
||||||
|
|
|
||||||
4
iframe/.formatter.exs
Normal file
4
iframe/.formatter.exs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[
|
||||||
|
import_deps: [:plug],
|
||||||
|
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||||
|
]
|
||||||
26
iframe/.gitignore
vendored
Normal file
26
iframe/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# The directory Mix will write compiled artifacts to.
|
||||||
|
/_build/
|
||||||
|
|
||||||
|
# If you run "mix test --cover", coverage assets end up here.
|
||||||
|
/cover/
|
||||||
|
|
||||||
|
# The directory Mix downloads your dependencies sources to.
|
||||||
|
/deps/
|
||||||
|
|
||||||
|
# Where third-party dependencies like ExDoc output generated docs.
|
||||||
|
/doc/
|
||||||
|
|
||||||
|
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||||
|
/.fetch
|
||||||
|
|
||||||
|
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||||
|
erl_crash.dump
|
||||||
|
|
||||||
|
# Also ignore archive artifacts (built via "mix archive.build").
|
||||||
|
*.ez
|
||||||
|
|
||||||
|
# Ignore package tarball (built via "mix hex.build").
|
||||||
|
livebook_space-*.tar
|
||||||
|
|
||||||
|
# Temporary files, for example, from tests.
|
||||||
|
/tmp/
|
||||||
38
iframe/Dockerfile
Normal file
38
iframe/Dockerfile
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Stage 1: build
|
||||||
|
FROM hexpm/elixir:1.13.2-erlang-24.1.7-alpine-3.15.0 AS build
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache build-base git
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install hex and rebar
|
||||||
|
RUN mix local.hex --force && \
|
||||||
|
mix local.rebar --force
|
||||||
|
|
||||||
|
# Set build ENV
|
||||||
|
ENV MIX_ENV=prod
|
||||||
|
|
||||||
|
# Install mix dependencies
|
||||||
|
COPY mix.exs mix.lock ./
|
||||||
|
RUN mix do deps.get, deps.compile
|
||||||
|
|
||||||
|
# Compile and build release
|
||||||
|
COPY priv priv
|
||||||
|
COPY lib lib
|
||||||
|
RUN mix do compile, release
|
||||||
|
|
||||||
|
# Stage 2: release image
|
||||||
|
FROM alpine:3.15.0
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache openssl ncurses-libs libstdc++
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the release build from the previous stage.
|
||||||
|
COPY --from=build /app/_build/prod/rel/livebook_space ./
|
||||||
|
|
||||||
|
ENV HOME=/app
|
||||||
|
|
||||||
|
CMD [ "/app/bin/livebook_space", "start" ]
|
||||||
5
iframe/README.md
Normal file
5
iframe/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Livebook iframes
|
||||||
|
|
||||||
|
Livebook executes custom JavaScript inside iframes. When running on http,
|
||||||
|
they are served on a separate port. For https, they are safely served by
|
||||||
|
[livebook.space](https://livebook.space), which runs this application.
|
||||||
24
iframe/fly.toml
Normal file
24
iframe/fly.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
app = "livebook-space"
|
||||||
|
|
||||||
|
kill_signal = "SIGTERM"
|
||||||
|
kill_timeout = 5
|
||||||
|
|
||||||
|
[env]
|
||||||
|
|
||||||
|
[[services]]
|
||||||
|
internal_port = 4000
|
||||||
|
protocol = "tcp"
|
||||||
|
|
||||||
|
[[services.ports]]
|
||||||
|
handlers = ["http"]
|
||||||
|
port = 80
|
||||||
|
|
||||||
|
[[services.ports]]
|
||||||
|
handlers = ["tls", "http"]
|
||||||
|
port = 443
|
||||||
|
|
||||||
|
[[services.tcp_checks]]
|
||||||
|
grace_period = "30s"
|
||||||
|
interval = "15s"
|
||||||
|
restart_limit = 6
|
||||||
|
timeout = "2s"
|
||||||
14
iframe/lib/livebook_space/application.ex
Normal file
14
iframe/lib/livebook_space/application.ex
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
defmodule LivebookSpace.Application do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Application
|
||||||
|
|
||||||
|
def start(_type, _args) do
|
||||||
|
children = [
|
||||||
|
{Plug.Cowboy, scheme: :http, plug: LivebookSpaceWeb.Plug, options: [port: 4000]}
|
||||||
|
]
|
||||||
|
|
||||||
|
opts = [strategy: :one_for_one, name: LivebookSpace.Supervisor]
|
||||||
|
Supervisor.start_link(children, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
26
iframe/lib/livebook_space_web/plug.ex
Normal file
26
iframe/lib/livebook_space_web/plug.ex
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
defmodule LivebookSpaceWeb.Plug do
|
||||||
|
use Plug.Builder
|
||||||
|
|
||||||
|
plug Plug.Static,
|
||||||
|
from: {:livebook_space, "priv/static/iframe"},
|
||||||
|
at: "/iframe",
|
||||||
|
# Iframes are versioned, so we cache them for long
|
||||||
|
cache_control_for_etags: "public, max-age=31536000",
|
||||||
|
headers: [
|
||||||
|
# Enable CORS to allow Livebook fetch the content and verify its integrity
|
||||||
|
{"access-control-allow-origin", "*"},
|
||||||
|
{"content-type", "text/html; charset=utf-8"}
|
||||||
|
]
|
||||||
|
|
||||||
|
plug Plug.Static, from: :livebook_space, at: "/"
|
||||||
|
|
||||||
|
plug :not_found
|
||||||
|
|
||||||
|
defp not_found(%{path_info: [], method: "GET"} = conn, _) do
|
||||||
|
call(%{conn | path_info: ["index.html"], request_path: "/index.html"}, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp not_found(conn, _) do
|
||||||
|
send_resp(conn, 404, "not found")
|
||||||
|
end
|
||||||
|
end
|
||||||
26
iframe/mix.exs
Normal file
26
iframe/mix.exs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
defmodule LivebookSpace.MixProject do
|
||||||
|
use Mix.Project
|
||||||
|
|
||||||
|
def project do
|
||||||
|
[
|
||||||
|
app: :livebook_space,
|
||||||
|
version: "0.1.0",
|
||||||
|
elixir: "~> 1.13",
|
||||||
|
start_permanent: Mix.env() == :prod,
|
||||||
|
deps: deps()
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def application do
|
||||||
|
[
|
||||||
|
extra_applications: [:logger],
|
||||||
|
mod: {LivebookSpace.Application, []}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp deps do
|
||||||
|
[
|
||||||
|
{:plug_cowboy, "~> 2.0"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
11
iframe/mix.lock
Normal file
11
iframe/mix.lock
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
%{
|
||||||
|
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
|
||||||
|
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||||
|
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
|
||||||
|
"mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"},
|
||||||
|
"plug": {:hex, :plug, "1.13.2", "33aba8e2b43ddd68d9d49b818ed2fb46da85f4ec3229bc4bcd0c981a640a4e71", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a95cdfe599e3524b98684376c3f3494cbfbc1f41fcddefc380cac3138dd7619d"},
|
||||||
|
"plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
|
||||||
|
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
|
||||||
|
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||||
|
"telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"},
|
||||||
|
}
|
||||||
191
iframe/priv/static/iframe/v1.html
Normal file
191
iframe/priv/static/iframe/v1.html
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Output</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: sans-serif;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Invoke the init function in a separate context for better isolation
|
||||||
|
function applyInit(init, ctx, data) {
|
||||||
|
init(ctx, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const state = {
|
||||||
|
token: null,
|
||||||
|
importPromise: null,
|
||||||
|
eventHandlers: {},
|
||||||
|
eventQueue: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function postMessage(message) {
|
||||||
|
window.parent.postMessage({ token: state.token, ...message }, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
root: document.getElementById("root"),
|
||||||
|
|
||||||
|
handleEvent(event, callback) {
|
||||||
|
if (state.eventHandlers[event]) {
|
||||||
|
throw new Error(
|
||||||
|
`Handler has already been defined for event "${event}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.eventHandlers[event] = callback;
|
||||||
|
|
||||||
|
while (
|
||||||
|
state.eventQueue.length > 0 &&
|
||||||
|
state.eventHandlers[state.eventQueue[0].event]
|
||||||
|
) {
|
||||||
|
const { event, payload } = state.eventQueue.shift();
|
||||||
|
const handler = state.eventHandlers[event];
|
||||||
|
handler(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
pushEvent(event, payload = null) {
|
||||||
|
postMessage({ type: "event", event, payload });
|
||||||
|
},
|
||||||
|
|
||||||
|
importCSS(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const linkEl = document.createElement("link");
|
||||||
|
linkEl.addEventListener(
|
||||||
|
"load",
|
||||||
|
(event) => {
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
linkEl.rel = "stylesheet";
|
||||||
|
linkEl.href = url;
|
||||||
|
document.head.appendChild(linkEl);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
if (event.source === window.parent) {
|
||||||
|
handleParentMessage(event.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleParentMessage(message) {
|
||||||
|
if (message.type === "readyReply") {
|
||||||
|
state.token = message.token;
|
||||||
|
onReady();
|
||||||
|
|
||||||
|
// Set the base URL for relative URLs
|
||||||
|
const baseUrlEl = document.createElement("base");
|
||||||
|
baseUrlEl.href = message.baseUrl;
|
||||||
|
document.head.appendChild(baseUrlEl);
|
||||||
|
// We already entered the script and the base URL change
|
||||||
|
// doesn't impact this import call, so we use the absolute
|
||||||
|
// URL instead
|
||||||
|
state.importPromise = import(`${message.baseUrl}${message.jsPath}`);
|
||||||
|
} else if (message.type === "init") {
|
||||||
|
state.importPromise.then((module) => {
|
||||||
|
const init = module.init;
|
||||||
|
|
||||||
|
if (!init) {
|
||||||
|
const fns = Object.keys(module);
|
||||||
|
throw new Error(
|
||||||
|
`Expected the widget JS module to export an init function, but found: ${fns.join(
|
||||||
|
", "
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyInit(init, ctx, message.data);
|
||||||
|
});
|
||||||
|
} else if (message.type === "event") {
|
||||||
|
const { event, payload } = message;
|
||||||
|
const handler = state.eventHandlers[event];
|
||||||
|
|
||||||
|
if (state.eventQueue.length === 0 && handler) {
|
||||||
|
handler(payload);
|
||||||
|
} else {
|
||||||
|
state.eventQueue.push({ event, payload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage({ type: "ready" });
|
||||||
|
|
||||||
|
function onReady() {
|
||||||
|
// Report height changes
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
postMessage({ type: "resize", height: document.body.scrollHeight });
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(document.body);
|
||||||
|
|
||||||
|
// Forward relevant DOM events
|
||||||
|
|
||||||
|
window.addEventListener("mousedown", (event) => {
|
||||||
|
postMessage({ type: "domEvent", event: { type: "mousedown" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("focus", (event) => {
|
||||||
|
postMessage({ type: "domEvent", event: { type: "focus" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (!isEditableElement(event.target)) {
|
||||||
|
postMessage({
|
||||||
|
type: "domEvent",
|
||||||
|
event: keyboardEventToPayload(event),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableElement(element) {
|
||||||
|
return element.matches("input, textarea, [contenteditable]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardEventToPayload(event) {
|
||||||
|
const {
|
||||||
|
altKey,
|
||||||
|
code,
|
||||||
|
ctrlKey,
|
||||||
|
isComposing,
|
||||||
|
key,
|
||||||
|
location,
|
||||||
|
metaKey,
|
||||||
|
repeat,
|
||||||
|
shiftKey,
|
||||||
|
} = event;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: event.type,
|
||||||
|
props: {
|
||||||
|
altKey,
|
||||||
|
code,
|
||||||
|
ctrlKey,
|
||||||
|
isComposing,
|
||||||
|
key,
|
||||||
|
location,
|
||||||
|
metaKey,
|
||||||
|
repeat,
|
||||||
|
shiftKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
206
iframe/priv/static/iframe/v2.html
Normal file
206
iframe/priv/static/iframe/v2.html
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Output</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: sans-serif;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Invoke the init function in a separate context for better isolation
|
||||||
|
function applyInit(init, ctx, data) {
|
||||||
|
init(ctx, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const state = {
|
||||||
|
token: null,
|
||||||
|
importPromise: null,
|
||||||
|
eventHandlers: {},
|
||||||
|
eventQueue: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function postMessage(message) {
|
||||||
|
window.parent.postMessage({ token: state.token, ...message }, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
root: document.getElementById("root"),
|
||||||
|
|
||||||
|
handleEvent(event, callback) {
|
||||||
|
if (state.eventHandlers[event]) {
|
||||||
|
throw new Error(
|
||||||
|
`Handler has already been defined for event "${event}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.eventHandlers[event] = callback;
|
||||||
|
|
||||||
|
while (
|
||||||
|
state.eventQueue.length > 0 &&
|
||||||
|
state.eventHandlers[state.eventQueue[0].event]
|
||||||
|
) {
|
||||||
|
const { event, payload } = state.eventQueue.shift();
|
||||||
|
const handler = state.eventHandlers[event];
|
||||||
|
handler(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
pushEvent(event, payload = null) {
|
||||||
|
postMessage({ type: "event", event, payload });
|
||||||
|
},
|
||||||
|
|
||||||
|
importCSS(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const linkEl = document.createElement("link");
|
||||||
|
linkEl.addEventListener(
|
||||||
|
"load",
|
||||||
|
(event) => {
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
linkEl.rel = "stylesheet";
|
||||||
|
linkEl.href = url;
|
||||||
|
document.head.appendChild(linkEl);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
if (event.source === window.parent) {
|
||||||
|
handleParentMessage(event.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleParentMessage(message) {
|
||||||
|
if (message.type === "readyReply") {
|
||||||
|
state.token = message.token;
|
||||||
|
onReady();
|
||||||
|
|
||||||
|
// Set the base URL for relative URLs
|
||||||
|
const baseUrlEl = document.createElement("base");
|
||||||
|
baseUrlEl.href = message.baseUrl;
|
||||||
|
document.head.appendChild(baseUrlEl);
|
||||||
|
// We already entered the script and the base URL change
|
||||||
|
// doesn't impact this import call, so we use the absolute
|
||||||
|
// URL instead
|
||||||
|
state.importPromise = import(`${message.baseUrl}${message.jsPath}`);
|
||||||
|
} else if (message.type === "init") {
|
||||||
|
state.importPromise
|
||||||
|
.then((module) => {
|
||||||
|
const init = module.init;
|
||||||
|
|
||||||
|
if (!init) {
|
||||||
|
const fns = Object.keys(module);
|
||||||
|
throw new Error(
|
||||||
|
`Expected the module to export an init function, but found: ${fns.join(
|
||||||
|
", "
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyInit(init, ctx, message.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
renderErrorMessage(
|
||||||
|
`Failed to load the widget JS module, got the following error:\n\n ${error.message}\n\nSee the browser console for more details. If running behind an authentication proxy, make sure the /public/* routes are publicly accessible.`
|
||||||
|
);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
} else if (message.type === "event") {
|
||||||
|
const { event, payload } = message;
|
||||||
|
const handler = state.eventHandlers[event];
|
||||||
|
|
||||||
|
if (state.eventQueue.length === 0 && handler) {
|
||||||
|
handler(payload);
|
||||||
|
} else {
|
||||||
|
state.eventQueue.push({ event, payload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage({ type: "ready" });
|
||||||
|
|
||||||
|
function onReady() {
|
||||||
|
// Report height changes
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
postMessage({ type: "resize", height: document.body.scrollHeight });
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(document.body);
|
||||||
|
|
||||||
|
// Forward relevant DOM events
|
||||||
|
|
||||||
|
window.addEventListener("mousedown", (event) => {
|
||||||
|
postMessage({ type: "domEvent", event: { type: "mousedown" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("focus", (event) => {
|
||||||
|
postMessage({ type: "domEvent", event: { type: "focus" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (!isEditableElement(event.target)) {
|
||||||
|
postMessage({
|
||||||
|
type: "domEvent",
|
||||||
|
event: keyboardEventToPayload(event),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableElement(element) {
|
||||||
|
return element.matches("input, textarea, [contenteditable]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardEventToPayload(event) {
|
||||||
|
const {
|
||||||
|
altKey,
|
||||||
|
code,
|
||||||
|
ctrlKey,
|
||||||
|
isComposing,
|
||||||
|
key,
|
||||||
|
location,
|
||||||
|
metaKey,
|
||||||
|
repeat,
|
||||||
|
shiftKey,
|
||||||
|
} = event;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: event.type,
|
||||||
|
props: {
|
||||||
|
altKey,
|
||||||
|
code,
|
||||||
|
ctrlKey,
|
||||||
|
isComposing,
|
||||||
|
key,
|
||||||
|
location,
|
||||||
|
metaKey,
|
||||||
|
repeat,
|
||||||
|
shiftKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderErrorMessage(message) {
|
||||||
|
ctx.root.innerHTML = `
|
||||||
|
<div style="color: #FF3E38; white-space: pre-wrap; word-break: break-word;">${message}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
iframe/priv/static/images/favicon.svg
Symbolic link
1
iframe/priv/static/images/favicon.svg
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../../../static/favicon.svg
|
||||||
1
iframe/priv/static/images/logo.png
Symbolic link
1
iframe/priv/static/images/logo.png
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../../../static/images/logo.png
|
||||||
62
iframe/priv/static/index.html
Normal file
62
iframe/priv/static/index.html
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Livebook.space</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: "Inter";
|
||||||
|
}
|
||||||
|
|
||||||
|
.root {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #0d1829;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration-line: underline;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="root">
|
||||||
|
<div class="lead">
|
||||||
|
<a href="https://livebook.dev">
|
||||||
|
<img src="/images/logo.png" height="128" width="128" alt="livebook" />
|
||||||
|
</a>
|
||||||
|
<div class="disclaimer">
|
||||||
|
This website serves iframes for Livebook apps. See
|
||||||
|
<a href="https://livebook.dev">livebook.dev</a>. See
|
||||||
|
<a href="https://github.com/livebook-dev/livebook.space">source</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2
iframe/priv/static/robots.txt
Normal file
2
iframe/priv/static/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
|
@ -42,6 +42,10 @@ defmodule Livebook do
|
||||||
config :livebook, authentication_mode: :disabled
|
config :livebook, authentication_mode: :disabled
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if port = Livebook.Config.port!("LIVEBOOK_IFRAME_PORT") do
|
||||||
|
config :livebook, :iframe_port, port
|
||||||
|
end
|
||||||
|
|
||||||
if runtime = Livebook.Config.default_runtime!("LIVEBOOK_DEFAULT_RUNTIME") do
|
if runtime = Livebook.Config.default_runtime!("LIVEBOOK_DEFAULT_RUNTIME") do
|
||||||
config :livebook, :default_runtime, runtime
|
config :livebook, :default_runtime, runtime
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,14 @@ defmodule Livebook.Application do
|
||||||
# Start the Node Pool for managing node names
|
# Start the Node Pool for managing node names
|
||||||
Livebook.Runtime.NodePool,
|
Livebook.Runtime.NodePool,
|
||||||
# Start the unique task dependencies
|
# Start the unique task dependencies
|
||||||
Livebook.UniqueTask,
|
Livebook.UniqueTask
|
||||||
|
] ++
|
||||||
|
iframe_server_specs() ++
|
||||||
|
[
|
||||||
# Start the Endpoint (http/https)
|
# Start the Endpoint (http/https)
|
||||||
# We skip the access url as we do our own logging below
|
# We skip the access url as we do our own logging below
|
||||||
{LivebookWeb.Endpoint, log_access_url: false}
|
{LivebookWeb.Endpoint, log_access_url: false}
|
||||||
] ++
|
] ++ app_specs()
|
||||||
app_specs()
|
|
||||||
|
|
||||||
opts = [strategy: :one_for_one, name: Livebook.Supervisor]
|
opts = [strategy: :one_for_one, name: Livebook.Supervisor]
|
||||||
|
|
||||||
|
|
@ -181,4 +183,16 @@ defmodule Livebook.Application do
|
||||||
else
|
else
|
||||||
defp app_specs, do: []
|
defp app_specs, do: []
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp iframe_server_specs() do
|
||||||
|
server? = Phoenix.Endpoint.server?(:livebook, LivebookWeb.Endpoint)
|
||||||
|
port = Livebook.Config.iframe_port()
|
||||||
|
|
||||||
|
if server? do
|
||||||
|
# Start the iframe endpoint on a different port
|
||||||
|
[{Plug.Cowboy, scheme: :http, plug: LivebookWeb.IframeEndpoint, options: [port: port]}]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,27 @@ defmodule Livebook.Config do
|
||||||
Application.get_env(:livebook, :data_path) || :filename.basedir(:user_data, "livebook")
|
Application.get_env(:livebook, :data_path) || :filename.basedir(:user_data, "livebook")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the configured port for the Livebook endpoint.
|
||||||
|
|
||||||
|
Note that the value may be `0`.
|
||||||
|
"""
|
||||||
|
@spec port() :: pos_integer() | 0
|
||||||
|
def port() do
|
||||||
|
Application.get_env(:livebook, LivebookWeb.Endpoint)[:http][:port]
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the configured port for the iframe endpoint.
|
||||||
|
"""
|
||||||
|
@spec iframe_port() :: pos_integer() | 0
|
||||||
|
def iframe_port() do
|
||||||
|
case port() do
|
||||||
|
0 -> 0
|
||||||
|
_ -> Application.fetch_env!(:livebook, :iframe_port)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
## Parsing
|
## Parsing
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
||||||
|
|
@ -458,4 +458,22 @@ defmodule Livebook.Utils do
|
||||||
defp memory_unit(:GB), do: 1024 * 1024 * 1024
|
defp memory_unit(:GB), do: 1024 * 1024 * 1024
|
||||||
defp memory_unit(:MB), do: 1024 * 1024
|
defp memory_unit(:MB), do: 1024 * 1024
|
||||||
defp memory_unit(:KB), do: 1024
|
defp memory_unit(:KB), do: 1024
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the port for an existing listener.
|
||||||
|
|
||||||
|
The listener references usually follow the pattern `plug.HTTP`
|
||||||
|
and `plug.HTTPS`.
|
||||||
|
"""
|
||||||
|
@spec get_port(:ranch.ref(), :inet.port_number()) :: :inet.port_number()
|
||||||
|
def get_port(ref, default) do
|
||||||
|
try do
|
||||||
|
:ranch.get_addr(ref)
|
||||||
|
rescue
|
||||||
|
_ -> default
|
||||||
|
else
|
||||||
|
{_, port} when is_integer(port) -> port
|
||||||
|
_ -> default
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,12 @@ defmodule LivebookCLI.Server do
|
||||||
config_entries = opts_to_config(opts, [])
|
config_entries = opts_to_config(opts, [])
|
||||||
put_config_entries(config_entries)
|
put_config_entries(config_entries)
|
||||||
|
|
||||||
port = Application.get_env(:livebook, LivebookWeb.Endpoint)[:http][:port]
|
case Livebook.Config.port() do
|
||||||
|
0 ->
|
||||||
|
# When a random port is configured, we can assume no collision
|
||||||
|
start_server(extra_args)
|
||||||
|
|
||||||
|
port ->
|
||||||
base_url = "http://localhost:#{port}"
|
base_url = "http://localhost:#{port}"
|
||||||
|
|
||||||
case check_endpoint_availability(base_url) do
|
case check_endpoint_availability(base_url) do
|
||||||
|
|
@ -101,6 +106,12 @@ defmodule LivebookCLI.Server do
|
||||||
)
|
)
|
||||||
|
|
||||||
:available ->
|
:available ->
|
||||||
|
start_server(extra_args)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_server(extra_args) do
|
||||||
# We configure the endpoint with `server: true`,
|
# We configure the endpoint with `server: true`,
|
||||||
# so it's gonna start listening
|
# so it's gonna start listening
|
||||||
case Application.ensure_all_started(:livebook) do
|
case Application.ensure_all_started(:livebook) do
|
||||||
|
|
@ -112,7 +123,6 @@ defmodule LivebookCLI.Server do
|
||||||
print_error("Livebook failed to start with reason: #{inspect(error)}")
|
print_error("Livebook failed to start with reason: #{inspect(error)}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
# Takes a list of {app, key, value} config entries
|
# Takes a list of {app, key, value} config entries
|
||||||
# and overrides the current applications' configuration accordingly.
|
# and overrides the current applications' configuration accordingly.
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,10 @@ defmodule LivebookWeb.Endpoint do
|
||||||
base =
|
base =
|
||||||
case struct_url() do
|
case struct_url() do
|
||||||
%URI{scheme: "https", port: 0} = uri ->
|
%URI{scheme: "https", port: 0} = uri ->
|
||||||
%{uri | port: get_port(__MODULE__.HTTPS, 433)}
|
%{uri | port: Livebook.Utils.get_port(__MODULE__.HTTPS, 433)}
|
||||||
|
|
||||||
%URI{scheme: "http", port: 0} = uri ->
|
%URI{scheme: "http", port: 0} = uri ->
|
||||||
%{uri | port: get_port(__MODULE__.HTTP, 80)}
|
%{uri | port: Livebook.Utils.get_port(__MODULE__.HTTP, 80)}
|
||||||
|
|
||||||
%URI{} = uri ->
|
%URI{} = uri ->
|
||||||
uri
|
uri
|
||||||
|
|
@ -109,15 +109,4 @@ defmodule LivebookWeb.Endpoint do
|
||||||
def access_url do
|
def access_url do
|
||||||
URI.to_string(access_struct_url())
|
URI.to_string(access_struct_url())
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_port(ref, default) do
|
|
||||||
try do
|
|
||||||
:ranch.get_addr(ref)
|
|
||||||
rescue
|
|
||||||
_ -> default
|
|
||||||
else
|
|
||||||
{_, port} when is_integer(port) -> port
|
|
||||||
_ -> default
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
42
lib/livebook_web/iframe_endpoint.ex
Normal file
42
lib/livebook_web/iframe_endpoint.ex
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
defmodule LivebookWeb.IframeEndpoint do
|
||||||
|
use Plug.Builder
|
||||||
|
|
||||||
|
defmodule AssetsMemoryProvider do
|
||||||
|
use LivebookWeb.MemoryProvider,
|
||||||
|
from: Path.expand("../../iframe/priv/static/iframe", __DIR__),
|
||||||
|
gzip: true
|
||||||
|
end
|
||||||
|
|
||||||
|
plug LivebookWeb.StaticPlug,
|
||||||
|
at: "/iframe",
|
||||||
|
file_provider: AssetsMemoryProvider,
|
||||||
|
gzip: true,
|
||||||
|
headers: [
|
||||||
|
# Enable CORS to allow Livebook fetch the content and verify its integrity
|
||||||
|
{"access-control-allow-origin", "*"},
|
||||||
|
# Iframes are versioned, so we cache them for long
|
||||||
|
{"cache-control", "public, max-age=31536000"},
|
||||||
|
# Specify the charset
|
||||||
|
{"content-type", "text/html; charset=utf-8"}
|
||||||
|
]
|
||||||
|
|
||||||
|
plug :not_found
|
||||||
|
|
||||||
|
defp not_found(conn, _) do
|
||||||
|
send_resp(conn, 404, "not found")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the listening port of the iframe endpoint.
|
||||||
|
"""
|
||||||
|
@spec port() :: pos_integer()
|
||||||
|
def port() do
|
||||||
|
livebook_port = Livebook.Config.port()
|
||||||
|
iframe_port = Livebook.Config.iframe_port()
|
||||||
|
|
||||||
|
case livebook_port do
|
||||||
|
0 -> Livebook.Utils.get_port(__MODULE__.HTTP, iframe_port)
|
||||||
|
_ -> iframe_port
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -11,7 +11,8 @@ defmodule LivebookWeb.Output.JSComponent do
|
||||||
data-assets-base-path={Routes.session_path(@socket, :show_asset, @session_id, @info.assets.hash, [])}
|
data-assets-base-path={Routes.session_path(@socket, :show_asset, @session_id, @info.assets.hash, [])}
|
||||||
data-js-path={@info.assets.js_path}
|
data-js-path={@info.assets.js_path}
|
||||||
data-session-token={session_token(@info.pid)}
|
data-session-token={session_token(@info.pid)}
|
||||||
data-session-id={@session_id}>
|
data-session-id={@session_id}
|
||||||
|
data-iframe-local-port={LivebookWeb.IframeEndpoint.port()}>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ defmodule LivebookWeb.StaticPlug do
|
||||||
# * `:file_provider` (**required**) - a module implementing `LivebookWeb.StaticPlug.Provider`
|
# * `:file_provider` (**required**) - a module implementing `LivebookWeb.StaticPlug.Provider`
|
||||||
# behaviour, responsible for resolving file requests
|
# behaviour, responsible for resolving file requests
|
||||||
#
|
#
|
||||||
# * `:at`, `:gzip` - same as `Plug.Static`
|
# * `:at`, `:gzip`, `:headers` - same as `Plug.Static`
|
||||||
|
|
||||||
@behaviour Plug
|
@behaviour Plug
|
||||||
|
|
||||||
|
|
@ -60,7 +60,8 @@ defmodule LivebookWeb.StaticPlug do
|
||||||
%{
|
%{
|
||||||
file_provider: file_provider,
|
file_provider: file_provider,
|
||||||
at: opts |> Keyword.fetch!(:at) |> Plug.Router.Utils.split(),
|
at: opts |> Keyword.fetch!(:at) |> Plug.Router.Utils.split(),
|
||||||
gzip?: Keyword.get(opts, :gzip, false)
|
gzip?: Keyword.get(opts, :gzip, false),
|
||||||
|
headers: Keyword.get(opts, :headers, [])
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -95,6 +96,7 @@ defmodule LivebookWeb.StaticPlug do
|
||||||
|> put_resp_header("content-type", content_type)
|
|> put_resp_header("content-type", content_type)
|
||||||
|> maybe_add_encoding(content_encoding)
|
|> maybe_add_encoding(content_encoding)
|
||||||
|> maybe_add_vary(options)
|
|> maybe_add_vary(options)
|
||||||
|
|> merge_resp_headers(options.headers)
|
||||||
|> send_resp(200, file.content)
|
|> send_resp(200, file.content)
|
||||||
|> halt()
|
|> halt()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue