From a28999dfb80a58363f2a6b147fe9b7a32d57bdc9 Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 15 Oct 2024 09:34:49 +0100 Subject: [PATCH 1/8] feat(pocs): initial trials and getting the wc working on 3011 and 3012 (standalone) - currently failing --- .env.example | 2 +- .vscode/launch.json | 2 +- public/PyodideServiceWorker.js | 40 ++ src/PyodideWorker.js | 9 +- .../PyodideRunner/PyodideRunner.jsx | 118 +++++- .../PyodideRunner/PyodideWorker.js | 358 ++++++++++++++++++ src/utils/externalLinkHelper.js | 2 +- webpack.component.config.js | 12 + 8 files changed, 537 insertions(+), 6 deletions(-) create mode 100644 public/PyodideServiceWorker.js create mode 100644 src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.js diff --git a/.env.example b/.env.example index b09fd4e7c..570bcff22 100644 --- a/.env.example +++ b/.env.example @@ -6,5 +6,5 @@ REACT_APP_PLAUSIBLE_DATA_DOMAIN='' REACT_APP_PLAUSIBLE_SOURCE='' REACT_APP_SENTRY_DSN='' REACT_APP_SENTRY_ENV='local' -PUBLIC_URL='http://localhost:3010' +PUBLIC_URL='http://localhost:3011' ASSETS_URL='http://localhost:3010' diff --git a/.vscode/launch.json b/.vscode/launch.json index 22dbf71bd..249490c42 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "type": "chrome", "request": "launch", "name": "Launch Chrome against localhost", - "url": "http://localhost:3010", + "url": "http://localhost:3011", "webRoot": "${workspaceFolder}" } ] diff --git a/public/PyodideServiceWorker.js b/public/PyodideServiceWorker.js new file mode 100644 index 000000000..4b8658f49 --- /dev/null +++ b/public/PyodideServiceWorker.js @@ -0,0 +1,40 @@ +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("fetch", (event) => { + if ( + event.request.cache === "only-if-cached" && + event.request.mode !== "same-origin" + ) { + return; + } + + console.log(event.request); + + const interceptedRequests = ["pyodide"]; + + if (interceptedRequests.some((str) => event.request.url.includes(str))) { + event.respondWith( + fetch(event.request) + .then((response) => { + console.log(`Intercepted: ${event.request.url}`); + + const body = response.body; + const status = response.status; + const headers = new Headers(response.headers); + const statusText = response.statusText; + + headers.set("Cross-Origin-Embedder-Policy", "require-corp"); + headers.set("Cross-Origin-Opener-Policy", "same-origin"); + + return new Response(body, { status, statusText, headers }); + }) + .catch(console.error), + ); + } +}); diff --git a/src/PyodideWorker.js b/src/PyodideWorker.js index 37d8e6cc1..cfce3ed94 100644 --- a/src/PyodideWorker.js +++ b/src/PyodideWorker.js @@ -8,6 +8,7 @@ const PyodideWorker = () => { importScripts("https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js"); const supportsAllFeatures = typeof SharedArrayBuffer !== "undefined"; + console.log(`crossOriginIsolated: ${globalThis.crossOriginIsolated}`); // eslint-disable-next-line no-restricted-globals if (!supportsAllFeatures && name !== "incremental-features") { @@ -361,9 +362,15 @@ const PyodideWorker = () => { }; globalThis.PyodideWorker = PyodideWorker; +// globalThis = { +// postMessage, +// onmessage, +// }; if (typeof module !== "undefined") { module.exports = { - PyodideWorker, + PyodideWorker: PyodideWorker, + // postMessage, + // onmessage, }; } diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index be616094a..f48e6e381 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -20,6 +20,82 @@ import OutputViewToggle from "../OutputViewToggle"; import { SettingsContext } from "../../../../../utils/settings"; import RunnerControls from "../../../../RunButton/RunnerControls"; +// import { PyodideWorker } from "worker-plugin/loader!../../../../../PyodideWorker"; + +/** + * A Worker that can be run on a different origin by respecting CORS + * By default, a Worker constructor that uses an url as its parameters ignores CORS + * and fails on a different origin. + * This way we can run web worker on scripts served by a CDN + */ +export class CorsWorker { + /** + * @param {string | URL} url - worker script URL + * @param {WorkerOptions} [options] - worker options + */ + constructor(url, options) { + this.url = url; + this.options = options; + const absoluteUrl = new URL(url, window.location.href).toString(); + const workerSource = `\ + /* global PyodideWorker */ + const urlString = ${JSON.stringify(absoluteUrl)} + const originURL = new URL(urlString) + const originalImportScripts = self.importScripts + self.importScripts = (url) => { + try { + originalImportScripts.call(self, new URL(url, originURL).toString()) + } catch (e) { + console.error('Failed to import script:', url, e); + throw e; + } + } + importScripts(urlString); + const pyodide = PyodideWorker(); +`; + const blob = new Blob([workerSource], { type: "application/javascript" }); + const objectURL = URL.createObjectURL(blob); + this.worker = new Worker(objectURL, options); + URL.revokeObjectURL(objectURL); + } + + getWorker() { + return this.worker; + } + + async createWorker() { + const f = await fetch(this.url); + const t = await f.text(); + const b = new Blob([t], { + type: "application/javascript", + }); + const url = URL.createObjectURL(b); + const worker = new Worker(url, this.options); + return worker; + } + + /* + The notes here are outside workerSource to not increase the ObjectURL size with + long explanations as comments + + Note 1 + ====== + Sometimes Webpack will try to import url with "blob:" prefixed and with relative + path as absolute path. + + Not only this will cause a security error due to different url, such as a "blob:" + protocol (meaning a CORS error), the path will be wrong. Any of those 2 problems + fails the importScripts. Since we can import JS with blobs, lets just remove "blob:" + prefix and fix the URL its content is a valid URL + + Note 2 + ====== + URL#pathname always starts with "/", we want to remove the / to be a relative path + to the Worker file URL + + */ +} + const PyodideRunner = ({ active }) => { const getWorkerURL = (url) => { const content = ` @@ -33,9 +109,27 @@ const PyodideRunner = ({ active }) => { return URL.createObjectURL(blob); }; - const workerUrl = getWorkerURL(`${process.env.PUBLIC_URL}/PyodideWorker.js`); + // Blob approach - works in web component but not in the editor + // Uncaught NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope': The script at 'http://localhost:3011/PyodideWorker.js' failed to load. + // const workerUrl = getWorkerURL(`${process.env.PUBLIC_URL}/PyodideWorker.js`); + // const pyodideWorker = useMemo(() => new Worker(workerUrl), []); + + // CORS worker - works in web component but not in the editor + // Uncaught NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope': The script at 'http://localhost:3012/en/projects/PyodideWorker.js' failed to load. + // const workerUrl = new CorsWorker("./PyodideWorker.js", { + const workerUrl = new CorsWorker( + `${process.env.PUBLIC_URL}/PyodideWorker.js`, + // { + // type: "classic", + // }, + ); + const pyodideWorker = useMemo(() => workerUrl.getWorker(), []); - const pyodideWorker = useMemo(() => new Worker(workerUrl), []); + // DOESN'T WORK + // const pyodideWorker = await workerUrl.createWorker(); + // const pyodideWorker = useMemo(() => workerUrl.createWorker(), []); + // const pyodideWorker = useMemo(() => new Worker(PyodideWorker), []); + // const pyodideWorker = useMemo(() => new Worker(`./PyodideWorker.js`, [])); if (!pyodideWorker) { console.error("PyodideWorker is not initialized"); @@ -65,6 +159,26 @@ const PyodideRunner = ({ active }) => { const [visuals, setVisuals] = useState([]); const [showRunner, setShowRunner] = useState(active); + // useEffect(() => { + // console.log("trying registering service worker"); + // if ("serviceWorker" in navigator) { + // console.log("registering service worker"); + // navigator.serviceWorker + // .register("./PyodideServiceWorker.js") + // // .register(`${process.env.PUBLIC_URL}/PyodideServiceWorker.js`) + // // .register(`${window.location.origin}/PyodideServiceWorker.js`) + // // .register(serviceWorker) + // // .register(getBlobURL(serviceWorker, "application/javascript")) + // // .register(serviceWorkerUrl) + // .then((registration) => { + // if (!registration.active || !navigator.serviceWorker.controller) { + // console.log("registered"); + // window.location.reload(); + // } + // }); + // } + // }, []); + useEffect(() => { if (pyodideWorker) { pyodideWorker.onmessage = ({ data }) => { diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.js b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.js new file mode 100644 index 000000000..b52d4f373 --- /dev/null +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.js @@ -0,0 +1,358 @@ +/* global globalThis, importScripts, loadPyodide, SharedArrayBuffer, Atomics, pygal, _internal_sense_hat */ + +// Nest the PyodideWorker function inside a globalThis object so we control when its initialised. +// Import scripts dynamically based on the environment +importScripts(`${process.env.PUBLIC_URL}/_internal_sense_hat.js`); +importScripts(`${process.env.PUBLIC_URL}/pygal.js`); +importScripts("https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js"); + +const supportsAllFeatures = typeof SharedArrayBuffer !== "undefined"; + +// eslint-disable-next-line no-restricted-globals +if (!supportsAllFeatures && name !== "incremental-features") { + console.warn( + [ + "The code editor will not be able to capture standard input or stop execution because these HTTP headers are not set:", + " - Cross-Origin-Opener-Policy: same-origin", + " - Cross-Origin-Embedder-Policy: require-corp", + "", + "If your app can cope with or without these features, please initialize the web worker with { name: 'incremental-features' } to silence this warning.", + "You can then check for the presence of { stdinBuffer, interruptBuffer } in the handleLoaded message to check whether these features are supported.", + "", + "If you definitely need these features, either configure your server to respond with the HTTP headers above, or register a service worker.", + "Once the HTTP headers are set, the browser will block cross-domain resources so you will need to add 'crossorigin' to