mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-12-09 05:05:55 +08:00
Add support for JS output widgets (#818)
* Add support for JS output widgets * Don't block session when fetching assets and batch calls * Improve path component sanitisation * Move fetching check to session caller * Attach origin to connect and event messages
This commit is contained in:
parent
10b78973cc
commit
844242ba80
34 changed files with 1406 additions and 23 deletions
|
|
@ -27,6 +27,7 @@ import DragAndDrop from "./drag_and_drop";
|
|||
import PasswordToggle from "./password_toggle";
|
||||
import KeyboardControl from "./keyboard_control";
|
||||
import morphdomCallbacks from "./morphdom_callbacks";
|
||||
import JSOutput from "./js_output";
|
||||
import { loadUserData } from "./lib/user";
|
||||
|
||||
const hooks = {
|
||||
|
|
@ -45,6 +46,7 @@ const hooks = {
|
|||
DragAndDrop,
|
||||
PasswordToggle,
|
||||
KeyboardControl,
|
||||
JSOutput,
|
||||
};
|
||||
|
||||
const csrfToken = document
|
||||
|
|
|
|||
191
assets/js/js_output/iframe.html
Normal file
191
assets/js/js_output/iframe.html
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Output</title>
|
||||
<style>
|
||||
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>
|
||||
202
assets/js/js_output/index.js
Normal file
202
assets/js/js_output/index.js
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { getAttributeOrThrow } from "../lib/attribute";
|
||||
import { randomToken } from "../lib/utils";
|
||||
|
||||
import iframeHtml from "./iframe.html";
|
||||
|
||||
/**
|
||||
* A hook used to render JS-enabled cell output.
|
||||
*
|
||||
* The JavaScript is defined by the user, so we sandbox the script
|
||||
* execution inside an iframe.
|
||||
*
|
||||
* The hook expects `js_output:<id>:init` event with `{ data }` payload,
|
||||
* the data is then used in the initial call to the custom JS module.
|
||||
*
|
||||
* Then, a number of `js_output:<id>:event` with `{ event }` payload can
|
||||
* be sent. The `event` is forwarded to the initialized component.
|
||||
*
|
||||
* Configuration:
|
||||
*
|
||||
* * `data-id` - a unique identifier used as messages scope
|
||||
*
|
||||
* * `data-assets-base-url` - the URL to resolve all relative paths
|
||||
* against in the iframe
|
||||
*
|
||||
* * `data-js-path` - a relative path for the initial output-specific
|
||||
* JS module
|
||||
*
|
||||
*/
|
||||
const JSOutput = {
|
||||
mounted() {
|
||||
this.props = getProps(this);
|
||||
this.state = {
|
||||
token: randomToken(),
|
||||
childReadyPromise: null,
|
||||
childReady: false,
|
||||
iframe: null,
|
||||
};
|
||||
|
||||
const iframePlaceholder = document.createElement("div");
|
||||
const iframe = document.createElement("iframe");
|
||||
this.state.iframe = iframe;
|
||||
|
||||
function postMessage(message) {
|
||||
iframe.contentWindow.postMessage(message, "*");
|
||||
}
|
||||
|
||||
this.state.childReadyPromise = new Promise((resolve, reject) => {
|
||||
this.handleWindowMessage = (event) => {
|
||||
if (event.source === iframe.contentWindow) {
|
||||
handleChildMessage(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", this.handleWindowMessage);
|
||||
|
||||
const handleChildMessage = (message) => {
|
||||
if (message.type === "ready" && !this.state.childReady) {
|
||||
postMessage({
|
||||
type: "readyReply",
|
||||
token: this.state.token,
|
||||
baseUrl: this.props.assetsBaseUrl,
|
||||
jsPath: this.props.jsPath,
|
||||
});
|
||||
this.state.childReady = true;
|
||||
resolve();
|
||||
} else {
|
||||
// Note: we use a random token to authorize child messages
|
||||
// and do our best to make this token unavailable for the
|
||||
// injected script on the child side. In the worst case scenario,
|
||||
// the script manages to extract the token and can then send
|
||||
// any of those messages, so we can treat this as a possible
|
||||
// surface for attacks. In this case the most "critical" actions
|
||||
// are shortcuts, neither of which is particularly dangerous.
|
||||
if (message.token !== this.state.token) {
|
||||
throw new Error("Token mismatch");
|
||||
}
|
||||
|
||||
if (message.type === "resize") {
|
||||
iframePlaceholder.style.height = `${message.height}px`;
|
||||
iframe.style.height = `${message.height}px`;
|
||||
} else if (message.type === "domEvent") {
|
||||
// Replicate the child events on the current element,
|
||||
// so that they are detected upstream in the session hook
|
||||
const event = replicateDomEvent(message.event);
|
||||
this.el.dispatchEvent(event);
|
||||
} else if (message.type === "event") {
|
||||
const { event, payload } = message;
|
||||
this.pushEvent("event", { event, payload });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const replicateDomEvent = (event) => {
|
||||
if (event.type === "focus") {
|
||||
return new FocusEvent("focus");
|
||||
} else if (event.type === "mousedown") {
|
||||
return new MouseEvent("mousedown", { bubbles: true });
|
||||
} else if (event.type === "keydown") {
|
||||
return new KeyboardEvent(event.type, event.props);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// When cells/sections are reordered, morphdom detaches and attaches
|
||||
// the relevant elements in the DOM. Consequently the output element
|
||||
// becomes temporarily detached from the DOM and attaching it back
|
||||
// would cause the iframe to reload. This behaviour is expected, see
|
||||
// https://github.com/whatwg/html/issues/5484 for more details. Reloading
|
||||
// that frequently is inefficient and also clears the iframe state,
|
||||
// which makes is very undesired in our case. To solve this, we insert
|
||||
// the iframe higher in the DOM tree, so that it's never affected by
|
||||
// reordering. Then, we insert a placeholder element in the output to
|
||||
// take up the expected space and we use absolute positioning to place
|
||||
// the iframe exactly over that placeholder. We set up observers to
|
||||
// track the changes in placeholder's position/size and we keep the
|
||||
// absolute iframe in sync.
|
||||
|
||||
const notebookEl = document.querySelector(`[data-element="notebook"]`);
|
||||
const notebookContentEl = notebookEl.querySelector([
|
||||
`[data-element="notebook-content"]`,
|
||||
]);
|
||||
const iframesEl = notebookEl.querySelector(
|
||||
`[data-element="output-iframes"]`
|
||||
);
|
||||
|
||||
iframe.className = "w-full h-0 absolute z-[1]";
|
||||
// Note that we use `srcdoc`, so the iframe has the same origin as the
|
||||
// parent. For this reason we intentionally don't use allow-same-origin,
|
||||
// as it would allow the iframe to effectively access the parent window.
|
||||
iframe.sandbox = "allow-scripts";
|
||||
iframe.srcdoc = iframeHtml;
|
||||
|
||||
iframesEl.appendChild(iframe);
|
||||
this.el.appendChild(iframePlaceholder);
|
||||
|
||||
function repositionIframe() {
|
||||
const notebookBox = notebookEl.getBoundingClientRect();
|
||||
const placeholderBox = iframePlaceholder.getBoundingClientRect();
|
||||
const top = placeholderBox.top - notebookBox.top + notebookEl.scrollTop;
|
||||
iframe.style.top = `${top}px`;
|
||||
const left =
|
||||
placeholderBox.left - notebookBox.left + notebookEl.scrollLeft;
|
||||
iframe.style.left = `${left}px`;
|
||||
iframe.style.height = `${placeholderBox.height}px`;
|
||||
iframe.style.width = `${placeholderBox.width}px`;
|
||||
}
|
||||
|
||||
// Most output position changes are accompanied by changes to the
|
||||
// notebook content element (adding cells, inserting newlines in
|
||||
// the editor, etc)
|
||||
this.resizeObserver = new ResizeObserver((entries) => repositionIframe());
|
||||
this.resizeObserver.observe(notebookContentEl);
|
||||
|
||||
// On lower level cell/section reordering is applied as element
|
||||
// removal followed by insert, consequently the intersection
|
||||
// between the output and notebook content changes (becomes none
|
||||
// for a brief moment)
|
||||
this.intersectionObserver = new IntersectionObserver(
|
||||
(entries) => repositionIframe(),
|
||||
{ root: notebookContentEl }
|
||||
);
|
||||
this.intersectionObserver.observe(iframePlaceholder);
|
||||
|
||||
// Event handlers
|
||||
|
||||
this.handleEvent(`js_output:${this.props.id}:init`, ({ data }) => {
|
||||
this.state.childReadyPromise.then(() => {
|
||||
postMessage({ type: "init", data });
|
||||
});
|
||||
});
|
||||
|
||||
this.handleEvent(
|
||||
`js_output:${this.props.id}:event`,
|
||||
({ event, payload }) => {
|
||||
this.state.childReadyPromise.then(() => {
|
||||
postMessage({ type: "event", event, payload });
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
updated() {
|
||||
this.props = getProps(this);
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
window.removeEventListener("message", this.handleWindowMessage);
|
||||
this.resizeObserver.disconnect();
|
||||
this.intersectionObserver.disconnect();
|
||||
this.state.iframe.remove();
|
||||
},
|
||||
};
|
||||
|
||||
function getProps(hook) {
|
||||
return {
|
||||
id: getAttributeOrThrow(hook.el, "data-id"),
|
||||
assetsBaseUrl: getAttributeOrThrow(hook.el, "data-assets-base-url"),
|
||||
jsPath: getAttributeOrThrow(hook.el, "data-js-path"),
|
||||
};
|
||||
}
|
||||
|
||||
export default JSOutput;
|
||||
|
|
@ -6,10 +6,7 @@ export function isMacOS() {
|
|||
}
|
||||
|
||||
export function isEditableElement(element) {
|
||||
return (
|
||||
["input", "textarea"].includes(element.tagName.toLowerCase()) ||
|
||||
element.contentEditable === "true"
|
||||
);
|
||||
return element.matches("input, textarea, [contenteditable]");
|
||||
}
|
||||
|
||||
export function clamp(n, x, y) {
|
||||
|
|
@ -71,7 +68,18 @@ export function decodeBase64(binary) {
|
|||
* Generates a random string.
|
||||
*/
|
||||
export function randomId() {
|
||||
const array = new Uint8Array(24);
|
||||
return randomString(24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random long string.
|
||||
*/
|
||||
export function randomToken() {
|
||||
return randomString(40);
|
||||
}
|
||||
|
||||
function randomString(byteSize) {
|
||||
const array = new Uint8Array(byteSize);
|
||||
crypto.getRandomValues(array);
|
||||
const byteString = String.fromCharCode(...array);
|
||||
return btoa(byteString);
|
||||
|
|
|
|||
290
assets/package-lock.json
generated
290
assets/package-lock.json
generated
|
|
@ -42,6 +42,7 @@
|
|||
"babel-loader": "^8.2.2",
|
||||
"css-loader": "^6.4.0",
|
||||
"css-minimizer-webpack-plugin": "^3.0.2",
|
||||
"html-loader": "^3.0.1",
|
||||
"jest": "^27.3.0",
|
||||
"mini-css-extract-plugin": "^2.1.0",
|
||||
"monaco-editor-webpack-plugin": "^5.0.0",
|
||||
|
|
@ -3370,6 +3371,16 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camel-case": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
|
||||
"integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"pascal-case": "^3.1.2",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
|
|
@ -3527,6 +3538,27 @@
|
|||
"integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/clean-css": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.2.2.tgz",
|
||||
"integrity": "sha512-/eR8ru5zyxKzpBLv9YZvMXgTSSQn7AdkMItMYynsFgGwTveCRVam9IUPFloE85B4vAIj05IuKmmEoV7/AQjT0w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"source-map": "~0.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clean-css/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
|
||||
|
|
@ -4437,6 +4469,16 @@
|
|||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dot-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
|
||||
"integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"no-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.3.871",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.871.tgz",
|
||||
|
|
@ -5277,6 +5319,15 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/hex-color-regex": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz",
|
||||
|
|
@ -5310,6 +5361,56 @@
|
|||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/html-loader": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-loader/-/html-loader-3.0.1.tgz",
|
||||
"integrity": "sha512-90Sxg9FhTkQEzmmHT2KOAQniTZgC72aifcfR0fZsuo1PJz0K4EXiTwxejTUombF8XShLj5RaZKYsUJhxR6G2dA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"html-minifier-terser": "^6.0.2",
|
||||
"parse5": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"webpack": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-minifier-terser": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
|
||||
"integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"camel-case": "^4.1.2",
|
||||
"clean-css": "^5.2.2",
|
||||
"commander": "^8.3.0",
|
||||
"he": "^1.2.0",
|
||||
"param-case": "^3.0.4",
|
||||
"relateurl": "^0.2.7",
|
||||
"terser": "^5.10.0"
|
||||
},
|
||||
"bin": {
|
||||
"html-minifier-terser": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/html-minifier-terser/node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/html-tags": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.1.0.tgz",
|
||||
|
|
@ -8018,6 +8119,15 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/lower-case": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
|
|
@ -9049,6 +9159,16 @@
|
|||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/no-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
||||
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lower-case": "^2.0.2",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/node-emoji": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
|
||||
|
|
@ -9312,6 +9432,16 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/param-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||
"integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"dot-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
|
@ -9362,6 +9492,16 @@
|
|||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
|
||||
"integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="
|
||||
},
|
||||
"node_modules/pascal-case": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
|
||||
"integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"no-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
|
|
@ -10443,6 +10583,15 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/relateurl": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
|
||||
"integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-3.0.0.tgz",
|
||||
|
|
@ -11188,9 +11337,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.9.0.tgz",
|
||||
"integrity": "sha512-h5hxa23sCdpzcye/7b8YqbE5OwKca/ni0RQz1uRX3tGh8haaGHqcuSqbGRybuAKNdntZ0mDgFNXPJ48xQ2RXKQ==",
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.10.0.tgz",
|
||||
"integrity": "sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"commander": "^2.20.0",
|
||||
|
|
@ -11202,6 +11351,14 @@
|
|||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"acorn": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
|
|
@ -15216,6 +15373,16 @@
|
|||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="
|
||||
},
|
||||
"camel-case": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
|
||||
"integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"pascal-case": "^3.1.2",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
|
|
@ -15328,6 +15495,23 @@
|
|||
"integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==",
|
||||
"dev": true
|
||||
},
|
||||
"clean-css": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.2.2.tgz",
|
||||
"integrity": "sha512-/eR8ru5zyxKzpBLv9YZvMXgTSSQn7AdkMItMYynsFgGwTveCRVam9IUPFloE85B4vAIj05IuKmmEoV7/AQjT0w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"source-map": "~0.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"cliui": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
|
||||
|
|
@ -16036,6 +16220,16 @@
|
|||
"domhandler": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"dot-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
|
||||
"integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"no-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"electron-to-chromium": {
|
||||
"version": "1.3.871",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.871.tgz",
|
||||
|
|
@ -16651,6 +16845,12 @@
|
|||
"space-separated-tokens": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"dev": true
|
||||
},
|
||||
"hex-color-regex": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz",
|
||||
|
|
@ -16681,6 +16881,39 @@
|
|||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true
|
||||
},
|
||||
"html-loader": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-loader/-/html-loader-3.0.1.tgz",
|
||||
"integrity": "sha512-90Sxg9FhTkQEzmmHT2KOAQniTZgC72aifcfR0fZsuo1PJz0K4EXiTwxejTUombF8XShLj5RaZKYsUJhxR6G2dA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"html-minifier-terser": "^6.0.2",
|
||||
"parse5": "^6.0.1"
|
||||
}
|
||||
},
|
||||
"html-minifier-terser": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
|
||||
"integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"camel-case": "^4.1.2",
|
||||
"clean-css": "^5.2.2",
|
||||
"commander": "^8.3.0",
|
||||
"he": "^1.2.0",
|
||||
"param-case": "^3.0.4",
|
||||
"relateurl": "^0.2.7",
|
||||
"terser": "^5.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"html-tags": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.1.0.tgz",
|
||||
|
|
@ -18664,6 +18897,15 @@
|
|||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.0.0.tgz",
|
||||
"integrity": "sha512-XhUjWR5CFaQ03JOP+iSDS9koy8T5jfoImCZ4XprElw3BXsSk4MpVYOLw/6LTDKZhO13PlAXnB5gS4MHQTpkSOw=="
|
||||
},
|
||||
"lower-case": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
|
|
@ -19329,6 +19571,16 @@
|
|||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"dev": true
|
||||
},
|
||||
"no-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
||||
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lower-case": "^2.0.2",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node-emoji": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
|
||||
|
|
@ -19524,6 +19776,16 @@
|
|||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true
|
||||
},
|
||||
"param-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||
"integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"dot-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
|
@ -19561,6 +19823,16 @@
|
|||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
|
||||
"integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="
|
||||
},
|
||||
"pascal-case": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
|
||||
"integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"no-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
|
|
@ -20283,6 +20555,12 @@
|
|||
"unified": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"relateurl": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
|
||||
"integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=",
|
||||
"dev": true
|
||||
},
|
||||
"remark-gfm": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-3.0.0.tgz",
|
||||
|
|
@ -20842,9 +21120,9 @@
|
|||
}
|
||||
},
|
||||
"terser": {
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.9.0.tgz",
|
||||
"integrity": "sha512-h5hxa23sCdpzcye/7b8YqbE5OwKca/ni0RQz1uRX3tGh8haaGHqcuSqbGRybuAKNdntZ0mDgFNXPJ48xQ2RXKQ==",
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.10.0.tgz",
|
||||
"integrity": "sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"commander": "^2.20.0",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
"babel-loader": "^8.2.2",
|
||||
"css-loader": "^6.4.0",
|
||||
"css-minimizer-webpack-plugin": "^3.0.2",
|
||||
"html-loader": "^3.0.1",
|
||||
"jest": "^27.3.0",
|
||||
"mini-css-extract-plugin": "^2.1.0",
|
||||
"monaco-editor-webpack-plugin": "^5.0.0",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ module.exports = (env, options) => {
|
|||
test: /\.(ttf|woff|woff2|eot|svg)$/,
|
||||
type: "asset/resource",
|
||||
},
|
||||
{
|
||||
test: /\.html$/i,
|
||||
loader: "html-loader",
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ defmodule Livebook.Application do
|
|||
Livebook.Session.FileGuard,
|
||||
# Start the Node Pool for managing node names
|
||||
Livebook.Runtime.NodePool,
|
||||
# Start the unique task dependencies
|
||||
Livebook.UniqueTask,
|
||||
# Start the Endpoint (http/https)
|
||||
LivebookWeb.Endpoint
|
||||
]
|
||||
|
|
|
|||
|
|
@ -560,7 +560,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
|
|||
|
||||
# ---
|
||||
|
||||
# TODO use Macro.classify_atom/1 on Elixir 1.14
|
||||
# TODO: use Macro.classify_atom/1 on Elixir 1.14
|
||||
|
||||
def macro_classify_atom(atom) do
|
||||
case macro_inner_classify(atom) do
|
||||
|
|
|
|||
|
|
@ -509,4 +509,22 @@ defmodule Livebook.Notebook do
|
|||
def forked(notebook) do
|
||||
%{notebook | name: notebook.name <> " - fork"}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Traverses cell outputs to find asset info matching
|
||||
the given hash.
|
||||
"""
|
||||
@spec find_asset_info(t(), String.t()) :: (asset_info :: map()) | nil
|
||||
def find_asset_info(notebook, hash) do
|
||||
Enum.find_value(notebook.sections, fn section ->
|
||||
Enum.find_value(section.cells, fn cell ->
|
||||
is_struct(cell, Cell.Elixir) &&
|
||||
Enum.find_value(cell.outputs, fn
|
||||
{:js_static, %{assets: %{hash: ^hash} = assets_info}, _data} -> assets_info
|
||||
{:js_dynamic, %{assets: %{hash: ^hash} = assets_info}, _pid} -> assets_info
|
||||
_ -> nil
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,12 +36,18 @@ defmodule Livebook.Notebook.Cell.Elixir do
|
|||
| {:vega_lite_static, spec :: map()}
|
||||
# Vega-Lite graphic with dynamic data
|
||||
| {:vega_lite_dynamic, widget_process :: pid()}
|
||||
# JavaScript powered output with static data
|
||||
| {:js_static, info :: map(), data :: term()}
|
||||
# JavaScript powered output with server process
|
||||
| {:js_dynamic, info :: map(), widget_process :: pid()}
|
||||
# Interactive data table
|
||||
| {:table_dynamic, widget_process :: pid()}
|
||||
# Dynamic wrapper for static output
|
||||
| {:frame_dynamic, widget_process :: pid()}
|
||||
# An input field
|
||||
| {:input, attrs :: map()}
|
||||
# A control element
|
||||
| {:control, attrs :: map()}
|
||||
# Internal output format for errors
|
||||
| {:error, message :: binary(), type :: :other | :runtime_restart_required}
|
||||
|
||||
|
|
|
|||
|
|
@ -232,4 +232,11 @@ defprotocol Livebook.Runtime do
|
|||
"""
|
||||
@spec standalone?(Runtime.t()) :: boolean()
|
||||
def standalone?(runtime)
|
||||
|
||||
@doc """
|
||||
Reads file at the given absolute path within the runtime
|
||||
file system.
|
||||
"""
|
||||
@spec read_file(Runtime.t(), String.t()) :: {:ok, binary()} | {:error, String.t()}
|
||||
def read_file(runtime, path)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -73,4 +73,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Attached do
|
|||
end
|
||||
|
||||
def standalone?(_runtime), do: false
|
||||
|
||||
def read_file(runtime, path) do
|
||||
ErlDist.RuntimeServer.read_file(runtime.server_pid, path)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -99,4 +99,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.ElixirStandalone do
|
|||
end
|
||||
|
||||
def standalone?(_runtime), do: true
|
||||
|
||||
def read_file(runtime, path) do
|
||||
ErlDist.RuntimeServer.read_file(runtime.server_pid, path)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -72,4 +72,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Embedded do
|
|||
end
|
||||
|
||||
def standalone?(_runtime), do: false
|
||||
|
||||
def read_file(runtime, path) do
|
||||
ErlDist.RuntimeServer.read_file(runtime.server_pid, path)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -98,6 +98,25 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
|||
GenServer.cast(pid, {:handle_intellisense, send_to, ref, request, locator})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads file at the given absolute path within the runtime
|
||||
file system.
|
||||
"""
|
||||
@spec read_file(pid(), String.t()) :: {:ok, binary()} | {:error, String.t()}
|
||||
def read_file(pid, path) do
|
||||
{result_ref, task_pid} = GenServer.call(pid, {:read_file, path})
|
||||
|
||||
monitor_ref = Process.monitor(task_pid)
|
||||
|
||||
receive do
|
||||
{:result, ^result_ref, result} ->
|
||||
result
|
||||
|
||||
{:DOWN, ^monitor_ref, :process, _object, _reason} ->
|
||||
{:error, "unexpected termination"}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops the manager.
|
||||
|
||||
|
|
@ -114,7 +133,7 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
|||
Process.send_after(self(), :check_owner, @await_owner_timeout)
|
||||
|
||||
{:ok, evaluator_supervisor} = ErlDist.EvaluatorSupervisor.start_link()
|
||||
{:ok, completion_supervisor} = Task.Supervisor.start_link()
|
||||
{:ok, task_supervisor} = Task.Supervisor.start_link()
|
||||
{:ok, object_tracker} = Livebook.Evaluator.ObjectTracker.start_link()
|
||||
|
||||
{:ok,
|
||||
|
|
@ -122,7 +141,7 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
|||
owner: nil,
|
||||
evaluators: %{},
|
||||
evaluator_supervisor: evaluator_supervisor,
|
||||
completion_supervisor: completion_supervisor,
|
||||
task_supervisor: task_supervisor,
|
||||
object_tracker: object_tracker
|
||||
}}
|
||||
end
|
||||
|
|
@ -221,7 +240,7 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
|||
Evaluator.handle_intellisense(evaluator, send_to, ref, request, evaluation_ref)
|
||||
else
|
||||
# Handle the request in a temporary process using an empty evaluation context
|
||||
Task.Supervisor.start_child(state.completion_supervisor, fn ->
|
||||
Task.Supervisor.start_child(state.task_supervisor, fn ->
|
||||
binding = []
|
||||
env = :elixir.env_for_eval([])
|
||||
response = Livebook.Intellisense.handle_request(request, binding, env)
|
||||
|
|
@ -232,6 +251,27 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
|||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:read_file, path}, {from_pid, _}, state) do
|
||||
# Delegate reading to a separate task and let the caller
|
||||
# wait for the response
|
||||
|
||||
result_ref = make_ref()
|
||||
|
||||
{:ok, task_pid} =
|
||||
Task.Supervisor.start_child(state.task_supervisor, fn ->
|
||||
result =
|
||||
case File.read(path) do
|
||||
{:ok, content} -> {:ok, content}
|
||||
{:error, posix} -> {:error, posix |> :file.format_error() |> List.to_string()}
|
||||
end
|
||||
|
||||
send(from_pid, {:result, result_ref, result})
|
||||
end)
|
||||
|
||||
{:reply, {result_ref, task_pid}, state}
|
||||
end
|
||||
|
||||
defp ensure_evaluator(state, container_ref) do
|
||||
if Map.has_key?(state.evaluators, container_ref) do
|
||||
state
|
||||
|
|
|
|||
|
|
@ -152,4 +152,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.MixStandalone do
|
|||
end
|
||||
|
||||
def standalone?(_runtime), do: true
|
||||
|
||||
def read_file(runtime, path) do
|
||||
ErlDist.RuntimeServer.read_file(runtime.server_pid, path)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -147,6 +147,41 @@ defmodule Livebook.Session do
|
|||
GenServer.call(pid, :get_notebook)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches assets matching the given hash.
|
||||
|
||||
The assets are cached locally and fetched from the runtime
|
||||
only once.
|
||||
|
||||
See `local_asset_path/2` for locating a specific asset.
|
||||
"""
|
||||
@spec fetch_assets(pid(), String.t()) :: :ok | {:error, String.t()}
|
||||
def fetch_assets(pid, hash) do
|
||||
local_assets_path = local_assets_path(hash)
|
||||
|
||||
if File.exists?(local_assets_path) do
|
||||
:ok
|
||||
else
|
||||
with {:ok, runtime, archive_path} <-
|
||||
GenServer.call(pid, {:get_runtime_and_archive_path, hash}) do
|
||||
fun = fn ->
|
||||
# Make sure the file hasn't been fetched by this point
|
||||
unless File.exists?(local_assets_path) do
|
||||
{:ok, archive_binary} = Runtime.read_file(runtime, archive_path)
|
||||
extract_archive!(archive_binary, local_assets_path)
|
||||
end
|
||||
end
|
||||
|
||||
# Fetch assets in a separate process and avoid several
|
||||
# simultaneous fateches of the same assets
|
||||
case Livebook.UniqueTask.run(hash, fun) do
|
||||
:ok -> :ok
|
||||
:error -> {:error, "failed to fetch assets"}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends notebook attributes update to the server.
|
||||
"""
|
||||
|
|
@ -490,6 +525,25 @@ defmodule Livebook.Session do
|
|||
{:reply, state.data, state}
|
||||
end
|
||||
|
||||
def handle_call({:get_runtime_and_archive_path, hash}, _from, state) do
|
||||
assets_info = Notebook.find_asset_info(state.data.notebook, hash)
|
||||
runtime = state.data.runtime
|
||||
|
||||
reply =
|
||||
cond do
|
||||
assets_info == nil ->
|
||||
{:error, "unknown hash"}
|
||||
|
||||
runtime == nil ->
|
||||
{:error, "no runtime"}
|
||||
|
||||
true ->
|
||||
{:ok, runtime, assets_info.archive_path}
|
||||
end
|
||||
|
||||
{:reply, reply, state}
|
||||
end
|
||||
|
||||
def handle_call(:get_notebook, _from, state) do
|
||||
{:reply, state.data.notebook, state}
|
||||
end
|
||||
|
|
@ -796,9 +850,10 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
defp session_tmp_dir(session_id) do
|
||||
tmp_dir = System.tmp_dir!() |> Path.expand()
|
||||
path = Path.join([tmp_dir, "livebook", "sessions", session_id]) <> "/"
|
||||
FileSystem.File.local(path)
|
||||
livebook_tmp_path()
|
||||
|> Path.join("sessions/#{session_id}")
|
||||
|> FileSystem.Utils.ensure_dir_path()
|
||||
|> FileSystem.File.local()
|
||||
end
|
||||
|
||||
defp cleanup_tmp_dir(session_id) do
|
||||
|
|
@ -806,6 +861,42 @@ defmodule Livebook.Session do
|
|||
FileSystem.File.remove(tmp_dir)
|
||||
end
|
||||
|
||||
defp local_assets_path(hash) do
|
||||
Path.join([livebook_tmp_path(), "assets", encode_path_component(hash)])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a local path to asset matching the given
|
||||
hash and path.
|
||||
|
||||
The file is not guaranteed to exist. See `fetch_assets/2`
|
||||
for fetching assets through a particular session.
|
||||
|
||||
The path is expected to be a simple relative path
|
||||
within the assets directory, otherwise an error is
|
||||
returned.
|
||||
"""
|
||||
@spec local_asset_path(String.t(), String.t()) :: {:ok, String.t()} | :error
|
||||
def local_asset_path(hash, asset_path) do
|
||||
assets_path = local_assets_path(hash)
|
||||
local_asset_path = Path.expand(asset_path, assets_path)
|
||||
|
||||
if String.starts_with?(local_asset_path, assets_path <> "/") do
|
||||
{:ok, local_asset_path}
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp encode_path_component(component) do
|
||||
String.replace(component, [".", "/", "\\", ":"], "_")
|
||||
end
|
||||
|
||||
defp livebook_tmp_path() do
|
||||
tmp_dir = System.tmp_dir!() |> Path.expand()
|
||||
Path.join(tmp_dir, "livebook")
|
||||
end
|
||||
|
||||
defp copy_images(state, source) do
|
||||
images_dir = images_dir_from_state(state)
|
||||
|
||||
|
|
@ -1125,6 +1216,10 @@ defmodule Livebook.Session do
|
|||
end
|
||||
end
|
||||
|
||||
defp extract_archive!(binary, path) do
|
||||
:ok = :erl_tar.extract({:binary, binary}, [:compressed, {:cwd, path}])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Determines locator of the evaluation that the given
|
||||
cell depends on.
|
||||
|
|
|
|||
83
lib/livebook/unique_task.ex
Normal file
83
lib/livebook/unique_task.ex
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
defmodule Livebook.UniqueTask.Task do
|
||||
@moduledoc false
|
||||
|
||||
use GenServer, restart: :temporary
|
||||
|
||||
@registry Livebook.UniqueTask.Registry
|
||||
|
||||
def start_link({key, fun}) do
|
||||
GenServer.start_link(__MODULE__, fun, name: {:via, Registry, {@registry, key}})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(fun) do
|
||||
{:ok, nil, {:continue, fun}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_continue(fun, state) do
|
||||
fun.()
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Livebook.UniqueTask do
|
||||
@moduledoc false
|
||||
|
||||
use Supervisor
|
||||
|
||||
@registry Livebook.UniqueTask.Registry
|
||||
@supervisor Livebook.UniqueTask.Supervisor
|
||||
@task Livebook.UniqueTask.Task
|
||||
|
||||
def start_link(_opts) do
|
||||
Supervisor.start_link(__MODULE__, {}, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init({}) do
|
||||
children = [
|
||||
{Registry, name: @registry, keys: :unique},
|
||||
{DynamicSupervisor, name: @supervisor, strategy: :one_for_one}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_all)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Runs the given function in a separate process,
|
||||
unless the key is already taken.
|
||||
|
||||
If another function is already running under the
|
||||
given key, this call only waits for it to finish
|
||||
and then returns the same status.
|
||||
|
||||
Returns `:ok` if function finishes successfully and
|
||||
`:error` if it crashes.
|
||||
"""
|
||||
@spec run(term(), function()) :: :ok | :error
|
||||
def run(key, fun) do
|
||||
pid =
|
||||
case Registry.lookup(@registry, key) do
|
||||
[{pid, _}] ->
|
||||
pid
|
||||
|
||||
[] ->
|
||||
case DynamicSupervisor.start_child(@supervisor, {@task, {key, fun}}) do
|
||||
{:ok, pid} -> pid
|
||||
{:error, {:already_started, pid}} -> pid
|
||||
end
|
||||
end
|
||||
|
||||
ref = Process.monitor(pid)
|
||||
|
||||
receive do
|
||||
{:DOWN, ^ref, :process, ^pid, reason} ->
|
||||
case reason do
|
||||
:normal -> :ok
|
||||
:noproc -> :ok
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -75,11 +75,10 @@ defmodule LivebookWeb.SessionController do
|
|||
|
||||
defp serve_with_cache(conn, file, :stale) do
|
||||
filename = FileSystem.File.name(file)
|
||||
content_type = MIME.from_path(filename)
|
||||
|
||||
with {:ok, content} <- FileSystem.File.read(file) do
|
||||
conn
|
||||
|> put_resp_header("content-type", content_type)
|
||||
|> put_content_type(filename)
|
||||
|> send_resp(200, content)
|
||||
|> then(&{:ok, &1})
|
||||
end
|
||||
|
|
@ -88,4 +87,79 @@ defmodule LivebookWeb.SessionController do
|
|||
defp serve_with_cache(conn, _file, :fresh) do
|
||||
{:ok, send_resp(conn, 304, "")}
|
||||
end
|
||||
|
||||
def show_asset(conn, %{"id" => id, "hash" => hash, "file_parts" => file_parts}) do
|
||||
asset_path = Path.join(file_parts)
|
||||
|
||||
# The request comes from a cross-origin iframe
|
||||
conn = allow_cors(conn)
|
||||
|
||||
# This route include session id, while we want the browser to
|
||||
# cache assets across sessions, so we only ensure the asset
|
||||
# is available and redirect to the corresponding route without
|
||||
# session id
|
||||
if ensure_asset?(id, hash, asset_path) do
|
||||
conn
|
||||
|> cache_permanently()
|
||||
|> put_status(:moved_permanently)
|
||||
|> redirect(to: Routes.session_path(conn, :show_cached_asset, hash, file_parts))
|
||||
else
|
||||
send_resp(conn, 404, "Not found")
|
||||
end
|
||||
end
|
||||
|
||||
def show_cached_asset(conn, %{"hash" => hash, "file_parts" => file_parts}) do
|
||||
asset_path = Path.join(file_parts)
|
||||
|
||||
# The request comes from a cross-origin iframe
|
||||
conn = allow_cors(conn)
|
||||
|
||||
case lookup_asset(hash, file_parts) do
|
||||
{:ok, local_asset_path} ->
|
||||
conn
|
||||
|> put_content_type(asset_path)
|
||||
|> cache_permanently()
|
||||
|> send_file(200, local_asset_path)
|
||||
|
||||
:error ->
|
||||
send_resp(conn, 404, "Not found")
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_asset?(session_id, hash, asset_path) do
|
||||
case lookup_asset(hash, asset_path) do
|
||||
{:ok, _local_asset_path} ->
|
||||
true
|
||||
|
||||
:error ->
|
||||
with {:ok, session} <- Sessions.fetch_session(session_id),
|
||||
:ok <- Session.fetch_assets(session.pid, hash) do
|
||||
true
|
||||
else
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp lookup_asset(hash, asset_path) do
|
||||
with {:ok, local_asset_path} <- Session.local_asset_path(hash, asset_path),
|
||||
true <- File.exists?(local_asset_path) do
|
||||
{:ok, local_asset_path}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp allow_cors(conn) do
|
||||
put_resp_header(conn, "access-control-allow-origin", "*")
|
||||
end
|
||||
|
||||
defp cache_permanently(conn) do
|
||||
put_resp_header(conn, "cache-control", "public, max-age=31536000")
|
||||
end
|
||||
|
||||
defp put_content_type(conn, path) do
|
||||
content_type = MIME.from_path(path)
|
||||
put_resp_header(conn, "content-type", content_type)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ defmodule LivebookWeb.Output do
|
|||
<%= render_output(output, %{
|
||||
id: "#{@id}-output#{group_idx}_#{idx}",
|
||||
socket: @socket,
|
||||
session_id: @session_id,
|
||||
runtime: @runtime,
|
||||
cell_validity_status: @cell_validity_status,
|
||||
input_values: @input_values
|
||||
|
|
@ -93,6 +94,22 @@ defmodule LivebookWeb.Output do
|
|||
)
|
||||
end
|
||||
|
||||
defp render_output({:js_static, info, data}, %{id: id, session_id: session_id}) do
|
||||
live_component(LivebookWeb.Output.JSStaticComponent,
|
||||
id: id,
|
||||
info: info,
|
||||
data: data,
|
||||
session_id: session_id
|
||||
)
|
||||
end
|
||||
|
||||
defp render_output({:js_dynamic, info, pid}, %{id: id, socket: socket, session_id: session_id}) do
|
||||
live_render(socket, LivebookWeb.Output.JSDynamicLive,
|
||||
id: id,
|
||||
session: %{"id" => id, "info" => info, "pid" => pid, "session_id" => session_id}
|
||||
)
|
||||
end
|
||||
|
||||
defp render_output({:table_dynamic, pid}, %{id: id, socket: socket}) do
|
||||
live_render(socket, LivebookWeb.Output.TableDynamicLive,
|
||||
id: id,
|
||||
|
|
@ -103,6 +120,7 @@ defmodule LivebookWeb.Output do
|
|||
defp render_output({:frame_dynamic, pid}, %{
|
||||
id: id,
|
||||
socket: socket,
|
||||
session_id: session_id,
|
||||
input_values: input_values,
|
||||
cell_validity_status: cell_validity_status
|
||||
}) do
|
||||
|
|
@ -111,6 +129,7 @@ defmodule LivebookWeb.Output do
|
|||
session: %{
|
||||
"id" => id,
|
||||
"pid" => pid,
|
||||
"session_id" => session_id,
|
||||
"input_values" => input_values,
|
||||
"cell_validity_status" => cell_validity_status
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ defmodule LivebookWeb.Output.FrameDynamicLive do
|
|||
%{
|
||||
"pid" => pid,
|
||||
"id" => id,
|
||||
"session_id" => session_id,
|
||||
"input_values" => input_values,
|
||||
"cell_validity_status" => cell_validity_status
|
||||
},
|
||||
|
|
@ -20,6 +21,7 @@ defmodule LivebookWeb.Output.FrameDynamicLive do
|
|||
assign(socket,
|
||||
id: id,
|
||||
output: nil,
|
||||
session_id: session_id,
|
||||
input_values: input_values,
|
||||
cell_validity_status: cell_validity_status
|
||||
)}
|
||||
|
|
@ -34,6 +36,7 @@ defmodule LivebookWeb.Output.FrameDynamicLive do
|
|||
outputs={[@output]}
|
||||
id={"#{@id}-frame"}
|
||||
socket={@socket}
|
||||
session_id={@session_id}
|
||||
runtime={nil}
|
||||
input_values={@input_values}
|
||||
cell_validity_status={@cell_validity_status} />
|
||||
|
|
|
|||
56
lib/livebook_web/live/output/js_dynamic_live.ex
Normal file
56
lib/livebook_web/live/output/js_dynamic_live.ex
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
defmodule LivebookWeb.Output.JSDynamicLive do
|
||||
use LivebookWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(
|
||||
_params,
|
||||
%{"pid" => pid, "id" => id, "info" => info, "session_id" => session_id},
|
||||
socket
|
||||
) do
|
||||
if connected?(socket) do
|
||||
send(pid, {:connect, self(), %{origin: self()}})
|
||||
end
|
||||
|
||||
assets_base_url = Routes.session_url(socket, :show_asset, session_id, info.assets.hash, [])
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
widget_pid: pid,
|
||||
id: id,
|
||||
assets_base_url: assets_base_url,
|
||||
js_path: info.assets.js_path
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id={"js-output-#{@id}"}
|
||||
phx-hook="JSOutput"
|
||||
phx-update="ignore"
|
||||
data-id={@id}
|
||||
data-assets-base-url={@assets_base_url}
|
||||
data-js-path={@js_path}>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("event", %{"event" => event, "payload" => payload}, socket) do
|
||||
send(socket.assigns.widget_pid, {:event, event, payload, %{origin: self()}})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:connect_reply, data}, socket) do
|
||||
{:noreply, push_event(socket, "js_output:#{socket.assigns.id}:init", %{"data" => data})}
|
||||
end
|
||||
|
||||
def handle_info({:event, event, payload}, socket) do
|
||||
{:noreply,
|
||||
push_event(socket, "js_output:#{socket.assigns.id}:event", %{
|
||||
"event" => event,
|
||||
"payload" => payload
|
||||
})}
|
||||
end
|
||||
end
|
||||
45
lib/livebook_web/live/output/js_static_component.ex
Normal file
45
lib/livebook_web/live/output/js_static_component.ex
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
defmodule LivebookWeb.Output.JSStaticComponent do
|
||||
use LivebookWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, initialized: false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
assets_base_url =
|
||||
Routes.session_url(socket, :show_asset, assigns.session_id, assigns.info.assets.hash, [])
|
||||
|
||||
socket =
|
||||
assign(socket,
|
||||
id: assigns.id,
|
||||
assets_base_url: assets_base_url,
|
||||
js_path: assigns.info.assets.js_path
|
||||
)
|
||||
|
||||
socket =
|
||||
if connected?(socket) and not socket.assigns.initialized do
|
||||
socket
|
||||
|> assign(initialized: true)
|
||||
|> push_event("js_output:#{socket.assigns.id}:init", %{"data" => assigns.data})
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id={"js-output-#{@id}"}
|
||||
phx-hook="JSOutput"
|
||||
phx-update="ignore"
|
||||
data-id={@id}
|
||||
data-assets-base-url={@assets_base_url}
|
||||
data-js-path={@js_path}>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -119,8 +119,9 @@ defmodule LivebookWeb.SessionLive do
|
|||
<.runtime_info data_view={@data_view} session={@session} socket={@socket} empty_default_runtime={@empty_default_runtime} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow overflow-y-auto scroll-smooth" data-element="notebook">
|
||||
<div class="w-full max-w-screen-lg px-16 mx-auto py-7">
|
||||
<div class="flex-grow overflow-y-auto scroll-smooth relative" data-element="notebook">
|
||||
<div data-element="output-iframes" phx-update="ignore" id="output-iframes"></div>
|
||||
<div class="w-full max-w-screen-lg px-16 mx-auto py-7" data-element="notebook-content">
|
||||
<div class="flex items-center pb-4 mb-6 space-x-4 border-b border-gray-200"
|
||||
data-element="notebook-headline"
|
||||
data-focusable-id="notebook"
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
outputs={@cell_view.outputs}
|
||||
id={"cell-#{@cell_view.id}-evaluation#{evaluation_number(@cell_view.evaluation_status, @cell_view.number_of_evaluations)}-outputs"}
|
||||
socket={@socket}
|
||||
session_id={@session_id}
|
||||
runtime={@runtime}
|
||||
cell_validity_status={@cell_view.validity_status}
|
||||
input_values={@cell_view.input_values} />
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@ defmodule LivebookWeb.Router do
|
|||
plug LivebookWeb.UserPlug
|
||||
end
|
||||
|
||||
pipeline :js_output_assets do
|
||||
plug :put_secure_browser_headers
|
||||
end
|
||||
|
||||
scope "/", LivebookWeb do
|
||||
pipe_through [:js_output_assets]
|
||||
|
||||
get "/sessions/assets/:hash/*file_parts", SessionController, :show_cached_asset
|
||||
get "/sessions/:id/assets/:hash/*file_parts", SessionController, :show_asset
|
||||
end
|
||||
|
||||
live_session :default, on_mount: LivebookWeb.CurrentUserHook do
|
||||
scope "/", LivebookWeb do
|
||||
pipe_through [:browser, :auth]
|
||||
|
|
|
|||
|
|
@ -254,4 +254,24 @@ defmodule Livebook.NotebookTest do
|
|||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "find_asset_info/2" do
|
||||
test "returns asset info matching the given type if found" do
|
||||
assets_info = %{archive: "/path/to/archive.tar.gz", hash: "abcd", js_path: "main.js"}
|
||||
js_info = %{assets: assets_info}
|
||||
output = {:js_static, js_info, %{}}
|
||||
|
||||
notebook = %{
|
||||
Notebook.new()
|
||||
| sections: [%{Section.new() | cells: [%{Cell.new(:elixir) | outputs: [output]}]}]
|
||||
}
|
||||
|
||||
assert ^assets_info = Notebook.find_asset_info(notebook, "abcd")
|
||||
end
|
||||
|
||||
test "returns nil if no matching info is found" do
|
||||
notebook = Notebook.new()
|
||||
assert Notebook.find_asset_info(notebook, "abcd") == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -168,6 +168,17 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServerTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "read_file/2" do
|
||||
test "returns file contents when the file exists", %{pid: pid} do
|
||||
assert {:ok, _} = RuntimeServer.read_file(pid, __ENV__.file)
|
||||
end
|
||||
|
||||
test "returns an error when the file does not exist", %{pid: pid} do
|
||||
assert {:error, "no such file or directory"} =
|
||||
RuntimeServer.read_file(pid, "/definitly_non_existent/file/path")
|
||||
end
|
||||
end
|
||||
|
||||
test "notifies the owner when an evaluator goes down", %{pid: pid} do
|
||||
code = """
|
||||
spawn_link(fn -> Process.exit(self(), :kill) end)
|
||||
|
|
|
|||
66
test/livebook/unique_task_test.exs
Normal file
66
test/livebook/unique_task_test.exs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
defmodule Livebook.UniqueTaskTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Livebook.UniqueTask
|
||||
|
||||
test "run/2 only awaits existing function call when the given key is taken" do
|
||||
parent = self()
|
||||
|
||||
fun = fn ->
|
||||
send(parent, {:ping_from_task, self()})
|
||||
|
||||
receive do
|
||||
:pong -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
spawn_link(fn ->
|
||||
result = UniqueTask.run("key1", fun)
|
||||
send(parent, {:result1, result})
|
||||
end)
|
||||
|
||||
spawn_link(fn ->
|
||||
result = UniqueTask.run("key1", fun)
|
||||
send(parent, {:result2, result})
|
||||
end)
|
||||
|
||||
assert_receive {:ping_from_task, task_pid}
|
||||
refute_receive {:ping_from_task, _other_task_pid}, 5
|
||||
# The function should be evaluated only once
|
||||
send(task_pid, :pong)
|
||||
|
||||
assert_receive {:result1, :ok}
|
||||
assert_receive {:result2, :ok}
|
||||
end
|
||||
|
||||
test "run/2 runs functions in parallel when different have different keys" do
|
||||
parent = self()
|
||||
|
||||
fun = fn ->
|
||||
send(parent, {:ping_from_task, self()})
|
||||
|
||||
receive do
|
||||
:pong -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
spawn_link(fn ->
|
||||
result = UniqueTask.run("key1", fun)
|
||||
send(parent, {:result1, result})
|
||||
end)
|
||||
|
||||
spawn_link(fn ->
|
||||
result = UniqueTask.run("key2", fun)
|
||||
send(parent, {:result2, result})
|
||||
end)
|
||||
|
||||
assert_receive {:ping_from_task, task1_pid}
|
||||
assert_receive {:ping_from_task, task2_pid}
|
||||
|
||||
send(task1_pid, :pong)
|
||||
send(task2_pid, :pong)
|
||||
|
||||
assert_receive {:result1, :ok}
|
||||
assert_receive {:result2, :ok}
|
||||
end
|
||||
end
|
||||
|
|
@ -139,4 +139,80 @@ defmodule LivebookWeb.SessionControllerTest do
|
|||
Session.close(session.pid)
|
||||
end
|
||||
end
|
||||
|
||||
describe "show_asset" do
|
||||
test "fetches assets and redirects to the session-less path", %{conn: conn} do
|
||||
%{notebook: notebook, hash: hash} = notebook_with_js_output()
|
||||
|
||||
conn = start_session_and_request_asset(conn, notebook, hash)
|
||||
|
||||
assert redirected_to(conn, 301) ==
|
||||
Routes.session_path(conn, :show_cached_asset, hash, ["main.js"])
|
||||
end
|
||||
|
||||
test "skips the session if assets are in cache", %{conn: conn} do
|
||||
%{notebook: notebook, hash: hash} = notebook_with_js_output()
|
||||
# Fetch the assets for the first time
|
||||
conn = start_session_and_request_asset(conn, notebook, hash)
|
||||
|
||||
# Use nonexistent session, so any communication would fail
|
||||
random_session_id = Livebook.Utils.random_node_aware_id()
|
||||
|
||||
conn =
|
||||
get(conn, Routes.session_path(conn, :show_asset, random_session_id, hash, ["main.js"]))
|
||||
|
||||
assert redirected_to(conn, 301) ==
|
||||
Routes.session_path(conn, :show_cached_asset, hash, ["main.js"])
|
||||
end
|
||||
end
|
||||
|
||||
describe "show_cached_asset" do
|
||||
test "returns not found when no matching assets are in the cache", %{conn: conn} do
|
||||
%{notebook: _notebook, hash: hash} = notebook_with_js_output()
|
||||
|
||||
conn = get(conn, Routes.session_path(conn, :show_cached_asset, hash, ["main.js"]))
|
||||
|
||||
assert conn.status == 404
|
||||
assert conn.resp_body == "Not found"
|
||||
end
|
||||
|
||||
test "returns the requestes asset if available in cache", %{conn: conn} do
|
||||
%{notebook: notebook, hash: hash} = notebook_with_js_output()
|
||||
# Fetch the assets for the first time
|
||||
conn = start_session_and_request_asset(conn, notebook, hash)
|
||||
|
||||
conn = get(conn, Routes.session_path(conn, :show_cached_asset, hash, ["main.js"]))
|
||||
|
||||
assert conn.status == 200
|
||||
assert "export function init(" <> _ = conn.resp_body
|
||||
end
|
||||
end
|
||||
|
||||
defp start_session_and_request_asset(conn, notebook, hash) do
|
||||
{:ok, session} = Sessions.create_session(notebook: notebook)
|
||||
# We need runtime in place to actually copy the archive
|
||||
Session.connect_runtime(session.pid, Livebook.Runtime.NoopRuntime.new())
|
||||
|
||||
conn = get(conn, Routes.session_path(conn, :show_asset, session.id, hash, ["main.js"]))
|
||||
|
||||
Session.close(session.pid)
|
||||
|
||||
conn
|
||||
end
|
||||
|
||||
defp notebook_with_js_output() do
|
||||
archive_path = Path.expand("../../support/assets.tar.gz", __DIR__)
|
||||
hash = "test-" <> Livebook.Utils.random_id()
|
||||
assets_info = %{archive_path: archive_path, hash: hash, js_path: "main.js"}
|
||||
output = {:js_static, %{assets: assets_info}, %{}}
|
||||
|
||||
notebook = %{
|
||||
Notebook.new()
|
||||
| sections: [
|
||||
%{Notebook.Section.new() | cells: [%{Notebook.Cell.new(:elixir) | outputs: [output]}]}
|
||||
]
|
||||
}
|
||||
|
||||
%{notebook: notebook, hash: hash}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -290,6 +290,46 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
|
||||
assert render(view) =~ "Dynamic output in frame"
|
||||
end
|
||||
|
||||
test "static js output sends the embedded data to the client", %{conn: conn, session: session} do
|
||||
js_info = %{assets: %{archive_path: "", hash: "abcd", js_path: "main.js"}}
|
||||
js_static_output = {:js_static, js_info, [1, 2, 3]}
|
||||
|
||||
section_id = insert_section(session.pid)
|
||||
cell_id = insert_text_cell(session.pid, section_id, :elixir)
|
||||
# Evaluate the cell
|
||||
Session.queue_cell_evaluation(session.pid, cell_id)
|
||||
# Send an additional output
|
||||
send(session.pid, {:evaluation_output, cell_id, js_static_output})
|
||||
|
||||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
||||
|
||||
assert_push_event(view, "js_output:" <> _, %{"data" => [1, 2, 3]})
|
||||
end
|
||||
|
||||
test "dynamic js output loads initial data from the widget server",
|
||||
%{conn: conn, session: session} do
|
||||
widget_pid =
|
||||
spawn(fn ->
|
||||
receive do
|
||||
{:connect, pid, %{}} -> send(pid, {:connect_reply, [1, 2, 3]})
|
||||
end
|
||||
end)
|
||||
|
||||
js_info = %{assets: %{archive_path: "", hash: "abcd", js_path: "main.js"}}
|
||||
js_dynamic_output = {:js_dynamic, js_info, widget_pid}
|
||||
|
||||
section_id = insert_section(session.pid)
|
||||
cell_id = insert_text_cell(session.pid, section_id, :elixir)
|
||||
# Evaluate the cell
|
||||
Session.queue_cell_evaluation(session.pid, cell_id)
|
||||
# Send an additional output
|
||||
send(session.pid, {:evaluation_output, cell_id, js_dynamic_output})
|
||||
|
||||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
||||
|
||||
assert_push_event(view, "js_output:" <> _, %{"data" => [1, 2, 3]})
|
||||
end
|
||||
end
|
||||
|
||||
describe "runtime settings" do
|
||||
|
|
|
|||
BIN
test/support/assets.tar.gz
Normal file
BIN
test/support/assets.tar.gz
Normal file
Binary file not shown.
|
|
@ -16,6 +16,13 @@ defmodule Livebook.Runtime.NoopRuntime do
|
|||
def drop_container(_, _), do: :ok
|
||||
def handle_intellisense(_, _, _, _, _), do: :ok
|
||||
def duplicate(_), do: {:ok, Livebook.Runtime.NoopRuntime.new()}
|
||||
def standalone?(_runtime), do: false
|
||||
def standalone?(_), do: false
|
||||
|
||||
def read_file(_, path) do
|
||||
case File.read(path) do
|
||||
{:ok, content} -> {:ok, content}
|
||||
{:error, posix} -> {:error, posix |> :file.format_error() |> List.to_string()}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue