Merge pull request #1979 from halfnelson/feature/repl-refactor

Sandbox iframe contents
pull/1983/head
Rich Harris 6 years ago committed by GitHub
commit 1097858ea2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "sapper dev", "dev": "sapper dev",
"sapper": "sapper build --legacy", "sapper": "sapper build --legacy",
"update": "sh scripts/update_template.sh && node scripts/get-contributors.js", "update": "node scripts/update_template.js && node scripts/get-contributors.js",
"start": "node __sapper__/build", "start": "node __sapper__/build",
"cy:run": "cypress run", "cy:run": "cypress run",
"cy:open": "cypress open", "cy:open": "cypress open",
@ -29,6 +29,7 @@
"passport-github": "^1.1.0", "passport-github": "^1.1.0",
"prismjs": "^1.15.0", "prismjs": "^1.15.0",
"session-file-store": "^1.2.0", "session-file-store": "^1.2.0",
"shelljs": "^0.8.3",
"sirv": "^0.2.0", "sirv": "^0.2.0",
"yootils": "0.0.14" "yootils": "0.0.14"
}, },

@ -0,0 +1,26 @@
const sh = require('shelljs');
const fs = require('fs')
sh.cd(__dirname+'/../')
// fetch svelte app
sh.rm('-rf','scripts/svelte-app')
sh.exec('npx degit sveltejs/template scripts/svelte-app')
// update repl-viewer.css based on template
sh.cp('scripts/svelte-app/public/global.css', 'static/repl-viewer.css')
// remove src (will be recreated client-side) and node_modules
sh.rm('-rf', 'scripts/svelte-app/src')
sh.rm('-rf', 'scripts/svelte-app/node_modules')
// build svelte-app.json
const appPath = 'scripts/svelte-app'
let files = []
for (const path of sh.find(appPath).filter(p => fs.lstatSync(p).isFile()) ) {
files.push({ path: path.slice(appPath.length + 1), data: fs.readFileSync(path).toString() });
}
fs.writeFileSync('static/svelte-app.json', JSON.stringify(files));

@ -1,15 +0,0 @@
cd `dirname $0`/..
# fetch svelte-app
rm -rf scripts/svelte-app
node_modules/.bin/degit sveltejs/template scripts/svelte-app
# update repl-viewer.css based on template
cp scripts/svelte-app/public/global.css static/repl-viewer.css
# remove src (will be recreated client-side) and node_modules
rm -rf scripts/svelte-app/src
rm -rf scripts/svelte-app/node_modules
# build svelte-app.json
node scripts/build-svelte-app-json.js `find scripts/svelte-app -type f`

@ -1,6 +1,7 @@
<script> <script>
import { onMount, createEventDispatcher } from 'svelte'; import { onMount, createEventDispatcher } from 'svelte';
import getLocationFromStack from '../../_utils/getLocationFromStack.js'; import getLocationFromStack from '../../_utils/getLocationFromStack.js';
import ReplProxy from '../../_utils/replProxy.js';
import { decode } from 'sourcemap-codec'; import { decode } from 'sourcemap-codec';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -14,29 +15,18 @@
export let error; export let error;
export function setProp(prop, value) { export function setProp(prop, value) {
if (component) { if (!replProxy) return;
component[prop] = value; replProxy.setProp(prop, value);
} }
}
let component; let hasComponent = false;
const refs = {}; const refs = {};
const importCache = {};
let pendingImports = 0; let pendingImports = 0;
let pending = false; let pending = false;
function fetchImport(id, curl) { let replProxy = null;
return new Promise((fulfil, reject) => {
curl([`https://bundle.run/${id}`]).then(module => {
importCache[id] = module;
fulfil(module);
}, err => {
console.error(err.stack);
reject(new Error(`Error loading ${id} from bundle.run`));
});
});
}
const namespaceSpecifier = /\*\s+as\s+(\w+)/; const namespaceSpecifier = /\*\s+as\s+(\w+)/;
const namedSpecifiers = /\{(.+)\}/; const namedSpecifiers = /\{(.+)\}/;
@ -77,39 +67,24 @@
let createComponent; let createComponent;
let init; let init;
onMount(() => { onMount(() => {
replProxy = new ReplProxy(refs.child);
refs.child.addEventListener('load', () => { refs.child.addEventListener('load', () => {
const iframe = refs.child;
const body = iframe.contentDocument.body;
const evalInIframe = iframe.contentWindow.eval;
// intercept links, so that we can use #hashes inside the iframe
body.addEventListener('click', event => {
if (event.which !== 1) return;
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
if (event.defaultPrevented) return;
// ensure target is a link
let el = event.target;
while (el && el.nodeName !== 'A') el = el.parentNode;
if (!el || el.nodeName !== 'A') return;
if (el.hasAttribute('download') || el.getAttribute('rel') === 'external' || el.target) return;
event.preventDefault();
if (el.href.startsWith(top.location.origin)) {
const url = new URL(el.href);
if (url.hash[0] === '#') {
iframe.contentWindow.location.hash = url.hash;
return;
}
}
window.open(el.href, '_blank'); replProxy.onPropUpdate = (prop, value) => {
}); dispatch('binding', { prop, value });
values_store.update(values => Object.assign({}, values, {
[prop]: value
}));
};
replProxy.onFetchProgress = (progress) => {
pendingImports = progress
}
replProxy.handleLinks();
let promise = null; let promise = null;
let updating = false; let updating = false;
@ -118,14 +93,18 @@
const init = () => { const init = () => {
if (sourceError) return; if (sourceError) return;
const imports = [];
const missingImports = bundle.imports.filter(x => !importCache[x]);
const removeStyles = () => { const removeStyles = () => {
const styles = iframe.contentDocument.querySelectorAll('style.svelte'); replProxy.eval(`
let i = styles.length; const styles = document.querySelectorAll('style.svelte');
while (i--) styles[i].parentNode.removeChild(styles[i]); let i = styles.length;
while (i--) styles[i].parentNode.removeChild(styles[i]);
`)
};
const destroyComponent = () => {
replProxy.eval(`if (window.component)
window.component.\$destroy();
window.component = null`);
}; };
const ready = () => { const ready = () => {
@ -133,18 +112,10 @@
if (toDestroy) { if (toDestroy) {
removeStyles(); removeStyles();
destroyComponent();
toDestroy.$destroy();
toDestroy = null; toDestroy = null;
} }
bundle.imports.forEach(x => {
const module = importCache[x];
const name = bundle.importMap.get(x);
iframe.contentWindow[name] = module;
});
if (ssr) { // this only gets generated if component uses lifecycle hooks if (ssr) { // this only gets generated if component uses lifecycle hooks
pending = true; pending = true;
createHtml(); createHtml();
@ -152,23 +123,22 @@
pending = false; pending = false;
createComponent(); createComponent();
} }
}; }
const createHtml = () => { const createHtml = () => {
try { replProxy.eval(`${ssr.code}
evalInIframe(`${ssr.code} var rendered = SvelteComponent.render(${JSON.stringify($values_store)});
var rendered = SvelteComponent.render(${JSON.stringify($values_store)});
if (rendered.css.code) {
if (rendered.css.code) { var style = document.createElement('style');
var style = document.createElement('style'); style.className = 'svelte';
style.className = 'svelte'; style.textContent = rendered.css.code;
style.textContent = rendered.css.code; document.head.appendChild(style);
document.head.appendChild(style); }
}
document.body.innerHTML = rendered.html;
document.body.innerHTML = rendered.html; `)
`) .catch( e => {
} catch (e) {
const loc = getLocationFromStack(e.stack, ssr.map); const loc = getLocationFromStack(e.stack, ssr.map);
if (loc) { if (loc) {
e.filename = loc.source; e.filename = loc.source;
@ -176,35 +146,26 @@
} }
error = e; error = e;
} });
}; };
const createComponent = () => { const createComponent = () => {
// remove leftover styles from SSR renderer // remove leftover styles from SSR renderer
if (ssr) removeStyles(); if (ssr) removeStyles();
try { replProxy.eval(`${dom.code}
evalInIframe(`${dom.code} document.body.innerHTML = '';
document.body.innerHTML = ''; window.location.hash = '';
window.location.hash = ''; window._svelteTransitionManager = null;
window._svelteTransitionManager = null;
window.component = new SvelteComponent({
var component = new SvelteComponent({ target: document.body,
target: document.body, props: ${JSON.stringify($values_store)}
props: ${JSON.stringify($values_store)} });
});`); `)
.catch(e => {
component = window.app = window.component = iframe.contentWindow.component;
// component.on('state', ({ current }) => {
// if (updating) return;
// updating = true;
// this.fire('data', { current });
// updating = false;
// });
} catch (e) {
// TODO show in UI // TODO show in UI
component = null; hasComponent = false;
const loc = getLocationFromStack(e.stack, dom.map); const loc = getLocationFromStack(e.stack, dom.map);
if (loc) { if (loc) {
@ -213,35 +174,23 @@
} }
error = e; error = e;
} });
}; };
pendingImports = missingImports.length; // Download the imports (sets them on iframe window when complete)
{
if (missingImports.length) {
let cancelled = false; let cancelled = false;
promise = replProxy.fetchImports(bundle);
promise = Promise.all( promise.cancel = () => { cancelled = true };
missingImports.map(id => fetchImport(id, iframe.contentWindow.curl).then(module => { promise.then(() => {
pendingImports -= 1;
return module;
}))
);
promise.cancel = () => cancelled = true;
promise
.then(() => {
if (cancelled) return; if (cancelled) return;
ready(); ready()
}) }).catch(e => {
.catch(e => {
if (cancelled) return; if (cancelled) return;
error = e; error = e;
}); });
} else {
ready();
} }
run = () => { run = () => {
pending = false; pending = false;
@ -254,30 +203,14 @@
if (!bundle) return; // TODO can this ever happen? if (!bundle) return; // TODO can this ever happen?
if (promise) promise.cancel(); if (promise) promise.cancel();
toDestroy = component; toDestroy = hasComponent;
component = null; hasComponent = false;
init(); init();
}; };
props_handler = props => { props_handler = props => {
if (!component) { replProxy.bindProps(props)
// TODO can this happen?
console.error(`no component to bind to`);
return;
}
props.forEach(prop => {
// TODO should there be a public API for binding?
// e.g. `component.$watch(prop, handler)`?
// (answer: probably)
component.$$.bound[prop] = value => {
dispatch('binding', { prop, value });
values_store.update(values => Object.assign({}, values, {
[prop]: value
}));
};
});
}; };
}); });
}); });
@ -346,7 +279,7 @@
</style> </style>
<div class="iframe-container"> <div class="iframe-container">
<iframe title="Result" bind:this={refs.child} class="{error || pending || pendingImports ? 'greyed-out' : ''}" srcdoc=' <iframe title="Result" bind:this={refs.child} sandbox="allow-scripts allow-popups" class="{error || pending || pendingImports ? 'greyed-out' : ''}" srcdoc='
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
@ -355,6 +288,7 @@
<body> <body>
<script src="/curl.js"></script> <script src="/curl.js"></script>
<script>curl.config(&#123; dontAddFileExt: /./ });</script> <script>curl.config(&#123; dontAddFileExt: /./ });</script>
<script src="/repl-runner.js"></script>
</body> </body>
</html> </html>
'></iframe> '></iframe>

@ -0,0 +1,87 @@
export default class ReplProxy {
constructor(iframe) {
this.iframe = iframe;
this.cmdId = 1;
this.pendingCmds = new Map();
this.onPropUpdate = null;
this.onFetchProgress = null;
window.addEventListener("message", ev => this.handleReplMessage(ev), false);
}
iframeCommand(command, args) {
return new Promise( (resolve, reject) => {
this.cmdId = this.cmdId + 1;
this.iframe.contentWindow.postMessage({
action: command,
cmdId: this.cmdId,
args: args
}, '*')
this.pendingCmds.set(this.cmdId, { resolve: resolve, reject: reject });
});
}
handleCommandMessage(cmdData) {
let action = cmdData.action;
let id = cmdData.cmdId;
let handler = this.pendingCmds.get(id);
if (handler) {
this.pendingCmds.delete(id);
if (action == "cmdError") {
let { message, stack } = cmdData;
let e = new Error(message);
e.stack = stack;
console.log("cmd fail");
handler.reject(e)
}
if (action == "cmdOk") {
console.log("cmd okay");
handler.resolve(cmdData.args)
}
} else {
console.error("command not found", id);
}
}
handleReplMessage(ev) {
let action = ev.data.action;
if ( action == "cmdError" || action == "cmdOk" ) {
this.handleCommandMessage(ev.data);
}
if (action == "prop_update") {
let { prop, value } = ev.data.args;
if (this.onPropUpdate)
this.onPropUpdate(prop, value)
}
if (action == "fetch_progress") {
if (this.onFetchProgress)
this.onFetchProgress(ev.data.args.remaining)
}
}
eval(script) {
return this.iframeCommand("eval", { script: script });
}
setProp(prop, value) {
return this.iframeCommand("set_prop", {prop, value})
}
bindProps(props) {
return this.iframeCommand("bind_props", { props: props })
}
handleLinks() {
return this.iframeCommand("catch_clicks", {});
}
fetchImports(bundle) {
return this.iframeCommand("fetch_imports", { bundle })
}
}

@ -1,5 +1,5 @@
export default function process_markdown(markdown) { export default function process_markdown(markdown) {
const match = /---\n([\s\S]+?)\n---/.exec(markdown); const match = /---\r?\n([\s\S]+?)\r?\n---/.exec(markdown);
const frontMatter = match[1]; const frontMatter = match[1];
const content = markdown.slice(match[0].length); const content = markdown.slice(match[0].length);

@ -0,0 +1,133 @@
(function (){
const importCache = {};
function fetchImport(id) {
return new Promise((fulfil, reject) => {
curl([`https://bundle.run/${id}`]).then(module => {
importCache[id] = module;
fulfil(module);
}, err => {
console.error(err.stack);
reject(new Error(`Error loading ${id} from bundle.run`));
});
});
}
function fetchImports(bundle, progressFunc) {
const missingImports = bundle.imports.filter(x => !importCache[x]);
let pendingImports = missingImports.length;
if (missingImports.length) {
let promise = Promise.all(
missingImports.map(id => fetchImport(id).then(() => {
pendingImports -= 1;
if (progressFunc) progressFunc(pendingImports);
}))
);
return promise
} else {
return Promise.resolve();
}
}
function handleMessage(ev) {
let { action, cmdId } = ev.data;
const sendMessage = (payload) => parent.postMessage( { ...payload }, ev.origin);
const sendReply = (payload) => sendMessage({ ...payload, cmdId })
const sendOk = () => sendReply({ action: "cmdOk" });
const sendError = (message, stack) => sendReply({ action: "cmdError", message, stack })
parent.postMessage({ action: "test" }, ev.origin);
if (action == "eval") {
let { script } = ev.data.args;
try {
eval(script);
sendOk();
} catch (e) {
sendError(e.message, e.stack)
}
}
if (action == "bind_props") {
let { props } = ev.data.args
if (!window.component) {
// TODO can this happen?
console.error(`no component to bind to`);
return;
}
props.forEach(prop => {
// TODO should there be a public API for binding?
// e.g. `component.$watch(prop, handler)`?
// (answer: probably)
window.component.$$.bound[prop] = value => {
sendMessage({ action:"prop_update", args: { prop, value } })
};
});
}
if (action == "set_prop") {
if (!window.component) {
return;
}
let { prop, value } = ev.data.args;
component[prop] = value;
}
if (action == "catch_clicks") {
let topOrigin = ev.origin;
document.body.addEventListener('click', event => {
if (event.which !== 1) return;
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
if (event.defaultPrevented) return;
// ensure target is a link
let el = event.target;
while (el && el.nodeName !== 'A') el = el.parentNode;
if (!el || el.nodeName !== 'A') return;
if (el.hasAttribute('download') || el.getAttribute('rel') === 'external' || el.target) return;
event.preventDefault();
if (el.href.startsWith(topOrigin)) {
const url = new URL(el.href);
if (url.hash[0] === '#') {
window.location.hash = url.hash;
return;
}
}
window.open(el.href, '_blank');
});
}
if (action == "fetch_imports") {
let { bundle } = ev.data.args;
fetchImports(bundle, (remaining) => {
sendMessage({action: "fetch_progress", args: { remaining }});
})
.then(() => {
bundle.imports.forEach(x=> {
const module = importCache[x];
const name = bundle.importMap.get(x);
window[name] = module;
});
sendOk();
})
.catch(e => {
sendError(e.message, e.stack);
})
}
}
window.addEventListener("message", handleMessage, false)
console.log("repl-runner initialized");
})();
Loading…
Cancel
Save