Skip to content

Commit 55674f8

Browse files
authored
Implement Scratch.external - VM part (#308)
1 parent 36d4d82 commit 55674f8

File tree

4 files changed

+158
-1
lines changed

4 files changed

+158
-1
lines changed

src/extension-support/tw-extension-api-common.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ const BlockType = require('./block-type');
33
const BlockShape = require('./tw-block-shape');
44
const TargetType = require('./target-type');
55
const Cast = require('../util/cast');
6+
const external = require('./tw-external');
67

78
const Scratch = {
89
ArgumentType,
910
BlockType,
1011
BlockShape,
1112
TargetType,
12-
Cast
13+
Cast,
14+
external
1315
};
1416

1517
module.exports = Scratch;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* @param {string} url
3+
* @returns {void} if URL is supported
4+
* @throws if URL is unsupported
5+
*/
6+
const checkURL = url => {
7+
// URL might be a very long data: URL, so try to avoid fully parsing it if we can.
8+
// The notable requirement here is that the URL must be an absolute URL, not something
9+
// relative to where the extension is loaded from or where the extension is running.
10+
// This ensures that the same extension file will always load resources from the same
11+
// place, regardless of how it is running or packaged or whatever else.
12+
if (
13+
!url.startsWith('http:') &&
14+
!url.startsWith('https:') &&
15+
!url.startsWith('data:') &&
16+
!url.startsWith('blob:')
17+
) {
18+
throw new Error(`Unsupported URL: ${url}`);
19+
}
20+
};
21+
22+
const external = {};
23+
24+
/**
25+
* @param {string} url
26+
* @template T
27+
* @returns {Promise<T>}
28+
*/
29+
external.importModule = url => {
30+
checkURL(url);
31+
// Need to specify webpackIgnore so that webpack compiles this directly to a call to import()
32+
// instead of trying making it try to use the webpack import system.
33+
return import(/* webpackIgnore: true */ url);
34+
};
35+
36+
/**
37+
* @param {string} url
38+
* @returns {Promise<Response>}
39+
*/
40+
external.fetch = async url => {
41+
checkURL(url);
42+
const res = await fetch(url);
43+
if (!res.ok) {
44+
throw new Error(`HTTP ${res.status} fetching ${url}`);
45+
}
46+
return res;
47+
};
48+
49+
/**
50+
* @param {string} url
51+
* @returns {Promise<string>}
52+
*/
53+
external.dataURL = async url => {
54+
const res = await external.fetch(url);
55+
const blob = await res.blob();
56+
return new Promise((resolve, reject) => {
57+
const fr = new FileReader();
58+
fr.onload = () => resolve(fr.result);
59+
fr.onerror = () => reject(fr.error);
60+
fr.readAsDataURL(blob);
61+
});
62+
};
63+
64+
/**
65+
* @param {string} url
66+
* @returns {Promise<Blob>}
67+
*/
68+
external.blob = async url => {
69+
const res = await external.fetch(url);
70+
return res.blob();
71+
};
72+
73+
/**
74+
* @param {string} url
75+
* @param {string} returnExpression
76+
* @template T
77+
* @returns {Promise<T>}
78+
*/
79+
external.evalAndReturn = async (url, returnExpression) => {
80+
const res = await external.fetch(url);
81+
const text = await res.text();
82+
const js = `${text};return ${returnExpression}`;
83+
const fn = new Function(js);
84+
return fn();
85+
};
86+
87+
module.exports = external;

test/unit/tw_extension_api_common.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,13 @@ test('Cast', t => {
3232
t.equal(ScratchCommon.Cast.toListIndex('1.5', 10, false), 1);
3333
t.end();
3434
});
35+
36+
test('external', t => {
37+
// has more tests in separate file, mostly just making sure that external exists at all
38+
ScratchCommon.external.fetch('data:text/plain;,test').then(r => {
39+
r.text().then(text => {
40+
t.equal(text, 'test');
41+
t.end();
42+
});
43+
});
44+
});

test/unit/tw_external.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const external = require('../../src/extension-support/tw-external');
2+
const {test} = require('tap');
3+
4+
test('importModule', t => {
5+
external.importModule('data:text/javascript;,export%20default%201').then(mod => {
6+
t.equal(mod.default, 1);
7+
t.end();
8+
});
9+
});
10+
11+
test('fetch', t => {
12+
external.fetch('data:text/plain;,test').then(res => {
13+
res.text().then(text => {
14+
t.equal(text, 'test');
15+
t.end();
16+
});
17+
});
18+
});
19+
20+
test('dataURL', t => {
21+
global.FileReader = class {
22+
readAsDataURL (blob) {
23+
blob.arrayBuffer().then(arrayBuffer => {
24+
const base64 = Buffer.from(arrayBuffer).toString('base64');
25+
this.result = `data:${blob.type};base64,${base64}`;
26+
this.onload();
27+
});
28+
}
29+
};
30+
31+
external.dataURL('data:text/plain;,doesthiswork').then(dataURL => {
32+
t.equal(dataURL, `data:text/plain;base64,${btoa('doesthiswork')}`);
33+
t.end();
34+
});
35+
});
36+
37+
test('blob', t => {
38+
external.blob('data:text/plain;,test').then(blob => {
39+
blob.text().then(blobText => {
40+
t.equal(blobText, 'test');
41+
t.end();
42+
});
43+
});
44+
});
45+
46+
test('evalAndReturn', t => {
47+
external.evalAndReturn('data:text/plain;,var%20x=20', 'x').then(result => {
48+
t.equal(result, 20);
49+
t.end();
50+
});
51+
});
52+
53+
test('relative URL throws', t => {
54+
external.fetch('./test.js').catch(err => {
55+
t.equal(err.message, `Unsupported URL: ./test.js`);
56+
t.end();
57+
});
58+
});

0 commit comments

Comments
 (0)