From 86be09c6408d8d14cd074f3f5c76400391a25906 Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Sat, 18 Oct 2025 17:54:59 -0500 Subject: [PATCH 1/8] Add Scratch.importDependency See https://github.com/TurboWarp/extensions/pull/2267 --- .../tw-extension-api-common.js | 4 +- src/extension-support/tw-import-dependency.js | 96 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/extension-support/tw-import-dependency.js diff --git a/src/extension-support/tw-extension-api-common.js b/src/extension-support/tw-extension-api-common.js index 1f5928fbc0f..fe6bfc7d7e4 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 importDependency = require('./tw-import-dependency'); const Scratch = { ArgumentType, BlockType, BlockShape, TargetType, - Cast + Cast, + importDependency }; module.exports = Scratch; diff --git a/src/extension-support/tw-import-dependency.js b/src/extension-support/tw-import-dependency.js new file mode 100644 index 00000000000..d9107c2418c --- /dev/null +++ b/src/extension-support/tw-import-dependency.js @@ -0,0 +1,96 @@ +/** + * @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 importDependency = {}; + +/** + * @param {string} url + * @template T + * @returns {Promise} + */ +importDependency.asModule = 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 dependency system. + return import(/* webpackIgnore: true */ url); +}; + +/** + * @param {string} url + * @returns {Promise|Response} + */ +importDependency.asFetch = url => { + checkURL(url); + return fetch(url); +}; + +/** + * @param {string} url + * @returns {Promise|string} + */ +importDependency.asDataURL = async url => { + checkURL(url); + const res = await 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} + */ +importDependency.asScriptTag = url => { + checkURL(url); + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Script error')); + script.src = url; + return script; + }); +}; + +/** + * @param {string} url + * @param {string} returnExpression + * @template T + * @returns {Promise|T} + */ +importDependency.asEval = async (url, returnExpression) => { + checkURL(url); + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`HTTP ${res.status} fetching ${url}`); + } + + const text = await res.text(); + const js = `${text};return ${returnExpression}`; + const fn = new Function(js); + return fn(); +}; + +module.exports = importDependency; From bc08f3705af06403ef6ee58e373ac836bb473c10 Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Sat, 18 Oct 2025 19:58:41 -0500 Subject: [PATCH 2/8] rename to Scratch.external, remove some unnecessary methods --- .../tw-extension-api-common.js | 4 +- ...tw-import-dependency.js => tw-external.js} | 47 ++++--------------- 2 files changed, 10 insertions(+), 41 deletions(-) rename src/extension-support/{tw-import-dependency.js => tw-external.js} (56%) diff --git a/src/extension-support/tw-extension-api-common.js b/src/extension-support/tw-extension-api-common.js index fe6bfc7d7e4..82e706de98f 100644 --- a/src/extension-support/tw-extension-api-common.js +++ b/src/extension-support/tw-extension-api-common.js @@ -3,7 +3,7 @@ const BlockType = require('./block-type'); const BlockShape = require('./tw-block-shape'); const TargetType = require('./target-type'); const Cast = require('../util/cast'); -const importDependency = require('./tw-import-dependency'); +const external = require('./tw-external'); const Scratch = { ArgumentType, @@ -11,7 +11,7 @@ const Scratch = { BlockShape, TargetType, Cast, - importDependency + external }; module.exports = Scratch; diff --git a/src/extension-support/tw-import-dependency.js b/src/extension-support/tw-external.js similarity index 56% rename from src/extension-support/tw-import-dependency.js rename to src/extension-support/tw-external.js index d9107c2418c..dd481fbdfb9 100644 --- a/src/extension-support/tw-import-dependency.js +++ b/src/extension-support/tw-external.js @@ -19,67 +19,36 @@ const checkURL = url => { } }; -const importDependency = {}; +const dependency = {}; /** * @param {string} url * @template T * @returns {Promise} */ -importDependency.asModule = url => { +dependency.import = 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 dependency system. + // instead of trying making it try to use the webpack import system. return import(/* webpackIgnore: true */ url); }; /** * @param {string} url - * @returns {Promise|Response} + * @returns {Promise} */ -importDependency.asFetch = url => { +dependency.fetch = url => { checkURL(url); return fetch(url); }; -/** - * @param {string} url - * @returns {Promise|string} - */ -importDependency.asDataURL = async url => { - checkURL(url); - const res = await 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} - */ -importDependency.asScriptTag = url => { - checkURL(url); - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.onload = () => resolve(); - script.onerror = () => reject(new Error('Script error')); - script.src = url; - return script; - }); -}; - /** * @param {string} url * @param {string} returnExpression * @template T - * @returns {Promise|T} + * @returns {Promise} */ -importDependency.asEval = async (url, returnExpression) => { +dependency.evalAndReturn = async (url, returnExpression) => { checkURL(url); const res = await fetch(url); @@ -93,4 +62,4 @@ importDependency.asEval = async (url, returnExpression) => { return fn(); }; -module.exports = importDependency; +module.exports = dependency; From 04c76af2fcb337ecf2cdb6318bbde1ed2da8f36d Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Sat, 18 Oct 2025 20:04:14 -0500 Subject: [PATCH 3/8] actually bring some of these back --- src/extension-support/tw-external.js | 48 ++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/src/extension-support/tw-external.js b/src/extension-support/tw-external.js index dd481fbdfb9..bce9e22016a 100644 --- a/src/extension-support/tw-external.js +++ b/src/extension-support/tw-external.js @@ -19,14 +19,14 @@ const checkURL = url => { } }; -const dependency = {}; +const external = {}; /** * @param {string} url * @template T * @returns {Promise} */ -dependency.import = url => { +external.import = 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. @@ -37,9 +37,37 @@ dependency.import = url => { * @param {string} url * @returns {Promise} */ -dependency.fetch = url => { +external.fetch = async url => { checkURL(url); - return fetch(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.blobURL = async url => { + const res = await external.fetch(url); + return res.blob(); }; /** @@ -48,18 +76,12 @@ dependency.fetch = url => { * @template T * @returns {Promise} */ -dependency.evalAndReturn = async (url, returnExpression) => { - checkURL(url); - - const res = await fetch(url); - if (!res.ok) { - throw new Error(`HTTP ${res.status} fetching ${url}`); - } - +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 = dependency; +module.exports = external; From 36bdd7942254b59154e029b093f4a6fdd52b3ab5 Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Sat, 18 Oct 2025 20:36:00 -0500 Subject: [PATCH 4/8] rename import -> importModule to keep typescript happy, fix name of blob function --- src/extension-support/tw-external.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extension-support/tw-external.js b/src/extension-support/tw-external.js index bce9e22016a..de7b3a9ad47 100644 --- a/src/extension-support/tw-external.js +++ b/src/extension-support/tw-external.js @@ -26,7 +26,7 @@ const external = {}; * @template T * @returns {Promise} */ -external.import = url => { +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. @@ -65,7 +65,7 @@ external.dataURL = async url => { * @param {string} url * @returns {Promise} */ -external.blobURL = async url => { +external.blob = async url => { const res = await external.fetch(url); return res.blob(); }; From bac761cc157780158cbd86659310a40b63a87deb Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Sat, 18 Oct 2025 23:16:34 -0500 Subject: [PATCH 5/8] add simple unit tests --- test/unit/tw_external.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 test/unit/tw_external.js diff --git a/test/unit/tw_external.js b/test/unit/tw_external.js new file mode 100644 index 00000000000..97260a56df7 --- /dev/null +++ b/test/unit/tw_external.js @@ -0,0 +1,36 @@ +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(); + }); + }); +}); + +// Node.js does not support FileReader (yet?) so not really possible to properly test dataURL + +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(); + }); +}); From 4dd579fa4e6f46cc2b06abff0022f83bb0c85ce5 Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Sat, 18 Oct 2025 23:22:21 -0500 Subject: [PATCH 6/8] ok we can unit test dataURL too --- test/unit/tw_external.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/test/unit/tw_external.js b/test/unit/tw_external.js index 97260a56df7..57a710b072b 100644 --- a/test/unit/tw_external.js +++ b/test/unit/tw_external.js @@ -17,7 +17,22 @@ test('fetch', t => { }); }); -// Node.js does not support FileReader (yet?) so not really possible to properly test dataURL +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 => { From ee218c5803403381523721a174bc5a9a173c0ba5 Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Sat, 18 Oct 2025 23:24:03 -0500 Subject: [PATCH 7/8] and sure test relative URL too --- test/unit/tw_external.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/unit/tw_external.js b/test/unit/tw_external.js index 57a710b072b..745c694bcdc 100644 --- a/test/unit/tw_external.js +++ b/test/unit/tw_external.js @@ -49,3 +49,10 @@ test('evalAndReturn', t => { t.end(); }); }); + +test('relative URL throws', t => { + external.fetch('./test.js').catch(err => { + t.equal(err.message, `Unsupported URL: ./test.js`); + t.end(); + }); +}); From 53cb28369a7b7053aa985939a6ab42d1b18baf5b Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Sat, 18 Oct 2025 23:27:37 -0500 Subject: [PATCH 8/8] test that ScratchCommon.external exists --- test/unit/tw_extension_api_common.js | 10 ++++++++++ 1 file changed, 10 insertions(+) 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(); + }); + }); +});