mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +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
|
@ -26,6 +26,7 @@ RUN mix do deps.get, deps.compile
|
|||
# Compile and build the release
|
||||
COPY rel rel
|
||||
COPY static static
|
||||
COPY iframe/priv/static/iframe iframe/priv/static/iframe
|
||||
COPY lib lib
|
||||
# We need README.md during compilation
|
||||
# (look for @external_resource "README.md")
|
||||
|
|
|
@ -93,17 +93,17 @@ and also for local usage in case you don't have Elixir installed.
|
|||
|
||||
```shell
|
||||
# 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
|
||||
# you can mount a local directory into the container.
|
||||
# Make sure to specify the user with "-u $(id -u):$(id -g)"
|
||||
# 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,
|
||||
# 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
|
||||
|
@ -160,6 +160,9 @@ The following environment variables configure Livebook:
|
|||
default path used on file selection screens and others. Defaults to the
|
||||
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.
|
||||
Must be a valid IPv4 or IPv6 address.
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Socket } from "phoenix";
|
||||
import { getAttributeOrThrow } from "../lib/attribute";
|
||||
import { getAttributeOrThrow, parseInteger } from "../lib/attribute";
|
||||
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
|
||||
* 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 = {
|
||||
mounted() {
|
||||
|
@ -135,7 +140,7 @@ const JSOutput = {
|
|||
|
||||
// Load the iframe content
|
||||
const iframesEl = document.querySelector(`[data-element="output-iframes"]`);
|
||||
initializeIframeSource(iframe).then(() => {
|
||||
initializeIframeSource(iframe, this.props.iframePort).then(() => {
|
||||
iframesEl.appendChild(iframe);
|
||||
});
|
||||
|
||||
|
@ -156,7 +161,10 @@ const JSOutput = {
|
|||
|
||||
const eventRef = channel.on(`event:${this.props.ref}`, (raw) => {
|
||||
const [[event], payload] = transportDecode(raw);
|
||||
postMessage({ type: "event", event, payload });
|
||||
|
||||
this.state.childReadyPromise.then(() => {
|
||||
postMessage({ type: "event", event, payload });
|
||||
});
|
||||
});
|
||||
|
||||
const errorRef = channel.on(`error:${this.props.ref}`, ({ message }) => {
|
||||
|
@ -201,6 +209,11 @@ function getProps(hook) {
|
|||
jsPath: getAttributeOrThrow(hook.el, "data-js-path"),
|
||||
sessionToken: getAttributeOrThrow(hook.el, "data-session-token"),
|
||||
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
|
||||
// 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
|
||||
// checksum against the expected value.
|
||||
//
|
||||
// (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
|
||||
// (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=";
|
||||
|
||||
function initializeIframeSource(iframe) {
|
||||
return verifyIframeSource().then(() => {
|
||||
function getIframeUrl(iframePort) {
|
||||
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 =
|
||||
"allow-scripts allow-same-origin allow-downloads allow-modals";
|
||||
iframe.allow =
|
||||
"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;
|
||||
|
||||
function verifyIframeSource() {
|
||||
function verifyIframeSource(iframeUrl) {
|
||||
if (!iframeVerificationPromise) {
|
||||
iframeVerificationPromise = fetch(IFRAME_URL)
|
||||
iframeVerificationPromise = fetch(iframeUrl)
|
||||
.then((response) => response.text())
|
||||
.then((html) => {
|
||||
if (sha256Base64(html) !== IFRAME_SHA256) {
|
||||
|
@ -325,6 +352,8 @@ function verifyIframeSource() {
|
|||
return iframeVerificationPromise;
|
||||
}
|
||||
|
||||
// Encoding/decoding of channel payloads
|
||||
|
||||
function transportEncode(meta, payload) {
|
||||
if (
|
||||
Array.isArray(payload) &&
|
||||
|
|
|
@ -23,6 +23,8 @@ config :livebook, LivebookWeb.Endpoint,
|
|||
]
|
||||
]
|
||||
|
||||
config :livebook, :iframe_port, 4001
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
# 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],
|
||||
server: true
|
||||
|
||||
config :livebook, :iframe_port, 8081
|
||||
|
||||
# Set log level to warning by default to reduce output
|
||||
config :logger, level: :warning
|
||||
|
||||
|
|
|
@ -6,6 +6,8 @@ config :livebook, LivebookWeb.Endpoint,
|
|||
http: [port: 4002],
|
||||
server: false
|
||||
|
||||
config :livebook, :iframe_port, 4003
|
||||
|
||||
# Print only warnings and errors during test
|
||||
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
|
||||
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
|
||||
config :livebook, :default_runtime, runtime
|
||||
end
|
||||
|
|
|
@ -30,12 +30,14 @@ defmodule Livebook.Application do
|
|||
# Start the Node Pool for managing node names
|
||||
Livebook.Runtime.NodePool,
|
||||
# Start the unique task dependencies
|
||||
Livebook.UniqueTask,
|
||||
# Start the Endpoint (http/https)
|
||||
# We skip the access url as we do our own logging below
|
||||
{LivebookWeb.Endpoint, log_access_url: false}
|
||||
Livebook.UniqueTask
|
||||
] ++
|
||||
app_specs()
|
||||
iframe_server_specs() ++
|
||||
[
|
||||
# Start the Endpoint (http/https)
|
||||
# We skip the access url as we do our own logging below
|
||||
{LivebookWeb.Endpoint, log_access_url: false}
|
||||
] ++ app_specs()
|
||||
|
||||
opts = [strategy: :one_for_one, name: Livebook.Supervisor]
|
||||
|
||||
|
@ -181,4 +183,16 @@ defmodule Livebook.Application do
|
|||
else
|
||||
defp app_specs, do: []
|
||||
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
|
||||
|
|
|
@ -66,6 +66,27 @@ defmodule Livebook.Config do
|
|||
Application.get_env(:livebook, :data_path) || :filename.basedir(:user_data, "livebook")
|
||||
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
|
||||
|
||||
@doc """
|
||||
|
|
|
@ -458,4 +458,22 @@ defmodule Livebook.Utils do
|
|||
defp memory_unit(:GB), do: 1024 * 1024 * 1024
|
||||
defp memory_unit(:MB), do: 1024 * 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
|
||||
|
|
|
@ -86,34 +86,44 @@ defmodule LivebookCLI.Server do
|
|||
config_entries = opts_to_config(opts, [])
|
||||
put_config_entries(config_entries)
|
||||
|
||||
port = Application.get_env(:livebook, LivebookWeb.Endpoint)[:http][:port]
|
||||
base_url = "http://localhost:#{port}"
|
||||
case Livebook.Config.port() do
|
||||
0 ->
|
||||
# When a random port is configured, we can assume no collision
|
||||
start_server(extra_args)
|
||||
|
||||
case check_endpoint_availability(base_url) do
|
||||
:livebook_running ->
|
||||
IO.puts("Livebook already running on #{base_url}")
|
||||
open_from_args(base_url, extra_args)
|
||||
port ->
|
||||
base_url = "http://localhost:#{port}"
|
||||
|
||||
:taken ->
|
||||
print_error(
|
||||
"Another application is already running on port #{port}." <>
|
||||
" Either ensure this port is free or specify a different port using the --port option"
|
||||
)
|
||||
case check_endpoint_availability(base_url) do
|
||||
:livebook_running ->
|
||||
IO.puts("Livebook already running on #{base_url}")
|
||||
open_from_args(base_url, extra_args)
|
||||
|
||||
:available ->
|
||||
# We configure the endpoint with `server: true`,
|
||||
# so it's gonna start listening
|
||||
case Application.ensure_all_started(:livebook) do
|
||||
{:ok, _} ->
|
||||
open_from_args(LivebookWeb.Endpoint.access_url(), extra_args)
|
||||
Process.sleep(:infinity)
|
||||
:taken ->
|
||||
print_error(
|
||||
"Another application is already running on port #{port}." <>
|
||||
" Either ensure this port is free or specify a different port using the --port option"
|
||||
)
|
||||
|
||||
{:error, error} ->
|
||||
print_error("Livebook failed to start with reason: #{inspect(error)}")
|
||||
:available ->
|
||||
start_server(extra_args)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp start_server(extra_args) do
|
||||
# We configure the endpoint with `server: true`,
|
||||
# so it's gonna start listening
|
||||
case Application.ensure_all_started(:livebook) do
|
||||
{:ok, _} ->
|
||||
open_from_args(LivebookWeb.Endpoint.access_url(), extra_args)
|
||||
Process.sleep(:infinity)
|
||||
|
||||
{:error, error} ->
|
||||
print_error("Livebook failed to start with reason: #{inspect(error)}")
|
||||
end
|
||||
end
|
||||
|
||||
# Takes a list of {app, key, value} config entries
|
||||
# and overrides the current applications' configuration accordingly.
|
||||
# Multiple values for the same key are deeply merged (provided they are keyword lists).
|
||||
|
|
|
@ -87,10 +87,10 @@ defmodule LivebookWeb.Endpoint do
|
|||
base =
|
||||
case struct_url() do
|
||||
%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 | port: get_port(__MODULE__.HTTP, 80)}
|
||||
%{uri | port: Livebook.Utils.get_port(__MODULE__.HTTP, 80)}
|
||||
|
||||
%URI{} = uri ->
|
||||
uri
|
||||
|
@ -109,15 +109,4 @@ defmodule LivebookWeb.Endpoint do
|
|||
def access_url do
|
||||
URI.to_string(access_struct_url())
|
||||
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
|
||||
|
|
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-js-path={@info.assets.js_path}
|
||||
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>
|
||||
"""
|
||||
end
|
||||
|
|
|
@ -45,7 +45,7 @@ defmodule LivebookWeb.StaticPlug do
|
|||
# * `:file_provider` (**required**) - a module implementing `LivebookWeb.StaticPlug.Provider`
|
||||
# behaviour, responsible for resolving file requests
|
||||
#
|
||||
# * `:at`, `:gzip` - same as `Plug.Static`
|
||||
# * `:at`, `:gzip`, `:headers` - same as `Plug.Static`
|
||||
|
||||
@behaviour Plug
|
||||
|
||||
|
@ -60,7 +60,8 @@ defmodule LivebookWeb.StaticPlug do
|
|||
%{
|
||||
file_provider: file_provider,
|
||||
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
|
||||
|
||||
|
@ -95,6 +96,7 @@ defmodule LivebookWeb.StaticPlug do
|
|||
|> put_resp_header("content-type", content_type)
|
||||
|> maybe_add_encoding(content_encoding)
|
||||
|> maybe_add_vary(options)
|
||||
|> merge_resp_headers(options.headers)
|
||||
|> send_resp(200, file.content)
|
||||
|> halt()
|
||||
|
||||
|
|
Loading…
Reference in a new issue