Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/extension-support/tw-extension-api-common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
87 changes: 87 additions & 0 deletions src/extension-support/tw-external.js
Original file line number Diff line number Diff line change
@@ -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<T>}
*/
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<Response>}
*/
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<string>}
*/
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<Blob>}
*/
external.blob = async url => {
const res = await external.fetch(url);
return res.blob();
};

/**
* @param {string} url
* @param {string} returnExpression
* @template T
* @returns {Promise<T>}
*/
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;
10 changes: 10 additions & 0 deletions test/unit/tw_extension_api_common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
58 changes: 58 additions & 0 deletions test/unit/tw_external.js
Original file line number Diff line number Diff line change
@@ -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();
});
});