diff --git a/assets/js/app.js b/assets/js/app.js index 5742422a9..7d703b564 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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 diff --git a/assets/js/js_output/iframe.html b/assets/js/js_output/iframe.html new file mode 100644 index 000000000..dc0d2e822 --- /dev/null +++ b/assets/js/js_output/iframe.html @@ -0,0 +1,191 @@ + + + + Output + + + +
+ + + diff --git a/assets/js/js_output/index.js b/assets/js/js_output/index.js new file mode 100644 index 000000000..94885633a --- /dev/null +++ b/assets/js/js_output/index.js @@ -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::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::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; diff --git a/assets/js/lib/utils.js b/assets/js/lib/utils.js index 03acf834b..2506d2e06 100644 --- a/assets/js/lib/utils.js +++ b/assets/js/lib/utils.js @@ -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); diff --git a/assets/package-lock.json b/assets/package-lock.json index 3e39498f5..8cd24712e 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -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", diff --git a/assets/package.json b/assets/package.json index 902236c12..2579e5135 100644 --- a/assets/package.json +++ b/assets/package.json @@ -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", diff --git a/assets/webpack.config.js b/assets/webpack.config.js index 5bb1f6a0a..cb16a0fd0 100644 --- a/assets/webpack.config.js +++ b/assets/webpack.config.js @@ -42,6 +42,10 @@ module.exports = (env, options) => { test: /\.(ttf|woff|woff2|eot|svg)$/, type: "asset/resource", }, + { + test: /\.html$/i, + loader: "html-loader", + }, ], }, plugins: [ diff --git a/lib/livebook/application.ex b/lib/livebook/application.ex index 17bd573b1..c79f048c4 100644 --- a/lib/livebook/application.ex +++ b/lib/livebook/application.ex @@ -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 ] diff --git a/lib/livebook/intellisense/identifier_matcher.ex b/lib/livebook/intellisense/identifier_matcher.ex index 0fc9fe970..7351fa6da 100644 --- a/lib/livebook/intellisense/identifier_matcher.ex +++ b/lib/livebook/intellisense/identifier_matcher.ex @@ -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 diff --git a/lib/livebook/notebook.ex b/lib/livebook/notebook.ex index 0235a43ce..9c7e1e469 100644 --- a/lib/livebook/notebook.ex +++ b/lib/livebook/notebook.ex @@ -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 diff --git a/lib/livebook/notebook/cell/elixir.ex b/lib/livebook/notebook/cell/elixir.ex index cb04381ef..9f07e7fc6 100644 --- a/lib/livebook/notebook/cell/elixir.ex +++ b/lib/livebook/notebook/cell/elixir.ex @@ -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} diff --git a/lib/livebook/runtime.ex b/lib/livebook/runtime.ex index cfa5a5de9..65b517de4 100644 --- a/lib/livebook/runtime.ex +++ b/lib/livebook/runtime.ex @@ -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 diff --git a/lib/livebook/runtime/attached.ex b/lib/livebook/runtime/attached.ex index 4d7ebb320..6c1126cd8 100644 --- a/lib/livebook/runtime/attached.ex +++ b/lib/livebook/runtime/attached.ex @@ -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 diff --git a/lib/livebook/runtime/elixir_standalone.ex b/lib/livebook/runtime/elixir_standalone.ex index ec95ee133..6a659d504 100644 --- a/lib/livebook/runtime/elixir_standalone.ex +++ b/lib/livebook/runtime/elixir_standalone.ex @@ -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 diff --git a/lib/livebook/runtime/embedded.ex b/lib/livebook/runtime/embedded.ex index 06fed0fa9..5a8463f66 100644 --- a/lib/livebook/runtime/embedded.ex +++ b/lib/livebook/runtime/embedded.ex @@ -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 diff --git a/lib/livebook/runtime/erl_dist/runtime_server.ex b/lib/livebook/runtime/erl_dist/runtime_server.ex index 20f316171..0ea3b00b4 100644 --- a/lib/livebook/runtime/erl_dist/runtime_server.ex +++ b/lib/livebook/runtime/erl_dist/runtime_server.ex @@ -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 diff --git a/lib/livebook/runtime/mix_standalone.ex b/lib/livebook/runtime/mix_standalone.ex index d54ebb49f..a030b806a 100644 --- a/lib/livebook/runtime/mix_standalone.ex +++ b/lib/livebook/runtime/mix_standalone.ex @@ -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 diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index fbad4e86a..72c0fa7ea 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -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. diff --git a/lib/livebook/unique_task.ex b/lib/livebook/unique_task.ex new file mode 100644 index 000000000..ceb048ac5 --- /dev/null +++ b/lib/livebook/unique_task.ex @@ -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 diff --git a/lib/livebook_web/controllers/session_controller.ex b/lib/livebook_web/controllers/session_controller.ex index e97f92c91..7eeb65ea8 100644 --- a/lib/livebook_web/controllers/session_controller.ex +++ b/lib/livebook_web/controllers/session_controller.ex @@ -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 diff --git a/lib/livebook_web/live/output.ex b/lib/livebook_web/live/output.ex index a1723505c..818388448 100644 --- a/lib/livebook_web/live/output.ex +++ b/lib/livebook_web/live/output.ex @@ -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 } diff --git a/lib/livebook_web/live/output/frame_dynamic_live.ex b/lib/livebook_web/live/output/frame_dynamic_live.ex index 68693862d..080188658 100644 --- a/lib/livebook_web/live/output/frame_dynamic_live.ex +++ b/lib/livebook_web/live/output/frame_dynamic_live.ex @@ -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} /> diff --git a/lib/livebook_web/live/output/js_dynamic_live.ex b/lib/livebook_web/live/output/js_dynamic_live.ex new file mode 100644 index 000000000..02d21a062 --- /dev/null +++ b/lib/livebook_web/live/output/js_dynamic_live.ex @@ -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""" +
+
+ """ + 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 diff --git a/lib/livebook_web/live/output/js_static_component.ex b/lib/livebook_web/live/output/js_static_component.ex new file mode 100644 index 000000000..e389d08ff --- /dev/null +++ b/lib/livebook_web/live/output/js_static_component.ex @@ -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""" +
+
+ """ + end +end diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 7b4b8dce3..09f1daa8e 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -119,8 +119,9 @@ defmodule LivebookWeb.SessionLive do <.runtime_info data_view={@data_view} session={@session} socket={@socket} empty_default_runtime={@empty_default_runtime} /> -
-
+
+
+
diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex index 0e6f56887..3b8e758a2 100644 --- a/lib/livebook_web/router.ex +++ b/lib/livebook_web/router.ex @@ -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] diff --git a/test/livebook/notebook_test.exs b/test/livebook/notebook_test.exs index 9f1c69255..4b2c251db 100644 --- a/test/livebook/notebook_test.exs +++ b/test/livebook/notebook_test.exs @@ -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 diff --git a/test/livebook/runtime/erl_dist/runtime_server_test.exs b/test/livebook/runtime/erl_dist/runtime_server_test.exs index dbee36f1a..a06cbd457 100644 --- a/test/livebook/runtime/erl_dist/runtime_server_test.exs +++ b/test/livebook/runtime/erl_dist/runtime_server_test.exs @@ -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) diff --git a/test/livebook/unique_task_test.exs b/test/livebook/unique_task_test.exs new file mode 100644 index 000000000..721a82f04 --- /dev/null +++ b/test/livebook/unique_task_test.exs @@ -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 diff --git a/test/livebook_web/controllers/session_controller_test.exs b/test/livebook_web/controllers/session_controller_test.exs index c5561ddef..3900b128c 100644 --- a/test/livebook_web/controllers/session_controller_test.exs +++ b/test/livebook_web/controllers/session_controller_test.exs @@ -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 diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs index ad5c991b2..f0e76c6f8 100644 --- a/test/livebook_web/live/session_live_test.exs +++ b/test/livebook_web/live/session_live_test.exs @@ -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 diff --git a/test/support/assets.tar.gz b/test/support/assets.tar.gz new file mode 100644 index 000000000..3ceaf1873 Binary files /dev/null and b/test/support/assets.tar.gz differ diff --git a/test/support/noop_runtime.ex b/test/support/noop_runtime.ex index 645f71c7f..01373b3e2 100644 --- a/test/support/noop_runtime.ex +++ b/test/support/noop_runtime.ex @@ -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