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:
Jonatan Kłosko 2021-12-24 14:18:34 +01:00 committed by GitHub
parent 10b78973cc
commit 844242ba80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1406 additions and 23 deletions

View file

@ -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

View 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>

View 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;

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -42,6 +42,10 @@ module.exports = (env, options) => {
test: /\.(ttf|woff|woff2|eot|svg)$/,
type: "asset/resource",
},
{
test: /\.html$/i,
loader: "html-loader",
},
],
},
plugins: [

View file

@ -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
]

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View 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

View file

@ -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

View file

@ -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
}

View file

@ -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} />

View 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

View 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

View file

@ -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"

View file

@ -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} />

View file

@ -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]

View file

@ -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

View file

@ -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)

View 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

View file

@ -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

View file

@ -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

Binary file not shown.

View file

@ -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