diff --git a/src/extension-support/tw-extension-api-common.js b/src/extension-support/tw-extension-api-common.js index 1f5928fbc0f..82e706de98f 100644 --- a/src/extension-support/tw-extension-api-common.js +++ b/src/extension-support/tw-extension-api-common.js @@ -3,13 +3,15 @@ const BlockType = require('./block-type'); const BlockShape = require('./tw-block-shape'); const TargetType = require('./target-type'); const Cast = require('../util/cast'); +const external = require('./tw-external'); const Scratch = { ArgumentType, BlockType, BlockShape, TargetType, - Cast + Cast, + external }; module.exports = Scratch; diff --git a/src/extension-support/tw-external.js b/src/extension-support/tw-external.js new file mode 100644 index 00000000000..de7b3a9ad47 --- /dev/null +++ b/src/extension-support/tw-external.js @@ -0,0 +1,87 @@ +/** + * @param {string} url + * @returns {void} if URL is supported + * @throws if URL is unsupported + */ +const checkURL = url => { + // URL might be a very long data: URL, so try to avoid fully parsing it if we can. + // The notable requirement here is that the URL must be an absolute URL, not something + // relative to where the extension is loaded from or where the extension is running. + // This ensures that the same extension file will always load resources from the same + // place, regardless of how it is running or packaged or whatever else. + if ( + !url.startsWith('http:') && + !url.startsWith('https:') && + !url.startsWith('data:') && + !url.startsWith('blob:') + ) { + throw new Error(`Unsupported URL: ${url}`); + } +}; + +const external = {}; + +/** + * @param {string} url + * @template T + * @returns {Promise} + */ +external.importModule = url => { + checkURL(url); + // Need to specify webpackIgnore so that webpack compiles this directly to a call to import() + // instead of trying making it try to use the webpack import system. + return import(/* webpackIgnore: true */ url); +}; + +/** + * @param {string} url + * @returns {Promise} + */ +external.fetch = async url => { + checkURL(url); + const res = await fetch(url); + if (!res.ok) { + throw new Error(`HTTP ${res.status} fetching ${url}`); + } + return res; +}; + +/** + * @param {string} url + * @returns {Promise} + */ +external.dataURL = async url => { + const res = await external.fetch(url); + const blob = await res.blob(); + return new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = () => resolve(fr.result); + fr.onerror = () => reject(fr.error); + fr.readAsDataURL(blob); + }); +}; + +/** + * @param {string} url + * @returns {Promise} + */ +external.blob = async url => { + const res = await external.fetch(url); + return res.blob(); +}; + +/** + * @param {string} url + * @param {string} returnExpression + * @template T + * @returns {Promise} + */ +external.evalAndReturn = async (url, returnExpression) => { + const res = await external.fetch(url); + const text = await res.text(); + const js = `${text};return ${returnExpression}`; + const fn = new Function(js); + return fn(); +}; + +module.exports = external; diff --git a/test/unit/tw_extension_api_common.js b/test/unit/tw_extension_api_common.js index 051fcf134fc..cbb1d6bbfa5 100644 --- a/test/unit/tw_extension_api_common.js +++ b/test/unit/tw_extension_api_common.js @@ -32,3 +32,13 @@ test('Cast', t => { t.equal(ScratchCommon.Cast.toListIndex('1.5', 10, false), 1); t.end(); }); + +test('external', t => { + // has more tests in separate file, mostly just making sure that external exists at all + ScratchCommon.external.fetch('data:text/plain;,test').then(r => { + r.text().then(text => { + t.equal(text, 'test'); + t.end(); + }); + }); +}); diff --git a/test/unit/tw_external.js b/test/unit/tw_external.js new file mode 100644 index 00000000000..745c694bcdc --- /dev/null +++ b/test/unit/tw_external.js @@ -0,0 +1,58 @@ +const external = require('../../src/extension-support/tw-external'); +const {test} = require('tap'); + +test('importModule', t => { + external.importModule('data:text/javascript;,export%20default%201').then(mod => { + t.equal(mod.default, 1); + t.end(); + }); +}); + +test('fetch', t => { + external.fetch('data:text/plain;,test').then(res => { + res.text().then(text => { + t.equal(text, 'test'); + t.end(); + }); + }); +}); + +test('dataURL', t => { + global.FileReader = class { + readAsDataURL (blob) { + blob.arrayBuffer().then(arrayBuffer => { + const base64 = Buffer.from(arrayBuffer).toString('base64'); + this.result = `data:${blob.type};base64,${base64}`; + this.onload(); + }); + } + }; + + external.dataURL('data:text/plain;,doesthiswork').then(dataURL => { + t.equal(dataURL, `data:text/plain;base64,${btoa('doesthiswork')}`); + t.end(); + }); +}); + +test('blob', t => { + external.blob('data:text/plain;,test').then(blob => { + blob.text().then(blobText => { + t.equal(blobText, 'test'); + t.end(); + }); + }); +}); + +test('evalAndReturn', t => { + external.evalAndReturn('data:text/plain;,var%20x=20', 'x').then(result => { + t.equal(result, 20); + t.end(); + }); +}); + +test('relative URL throws', t => { + external.fetch('./test.js').catch(err => { + t.equal(err.message, `Unsupported URL: ./test.js`); + t.end(); + }); +});