diff --git a/config/buildArtifacts.js b/config/buildArtifacts.js new file mode 100644 index 000000000..9edc55225 --- /dev/null +++ b/config/buildArtifacts.js @@ -0,0 +1,59 @@ +const path = require("path"); + +const getScratchStaticDir = (rootDir) => + path.resolve( + rootDir, + "node_modules/@RaspberryPiFoundation/scratch-gui/dist/static", + ); + +const getScratchChunkDir = (rootDir) => + path.resolve( + rootDir, + "node_modules/@RaspberryPiFoundation/scratch-gui/dist/chunks", + ); + +const mainCopyPatterns = [ + { from: "public", to: "" }, + { from: "src/projects", to: "projects" }, + { + from: "node_modules/@raspberrypifoundation/python-friendly-error-messages/copydecks", + to: "python-error-copydecks", + }, +]; + +const getScratchCopyPatterns = ({ scratchStaticDir, scratchChunkDir }) => [ + { from: scratchStaticDir, to: "scratch-gui/static" }, + { from: `${scratchStaticDir}/assets`, to: "vendor/static/assets" }, + { from: scratchChunkDir, to: "chunks" }, + { + from: "node_modules/scratchReactVendor/umd/react.production.min.js", + to: "vendor/react.production.min.js", + }, + { + from: "node_modules/scratchReactDomVendor/umd/react-dom.production.min.js", + to: "vendor/react-dom.production.min.js", + }, + { + from: "node_modules/redux/dist/redux.min.js", + to: "vendor/redux.min.js", + }, + { + from: "node_modules/react-redux/dist/react-redux.min.js", + to: "vendor/react-redux.min.js", + }, + { + from: "node_modules/@RaspberryPiFoundation/scratch-gui/dist/scratch-gui.js", + to: "vendor/scratch-gui.js", + }, + { + from: "node_modules/@RaspberryPiFoundation/scratch-gui/dist/scratch-gui.js.LICENSE.txt", + to: "vendor/scratch-gui.js.LICENSE.txt", + }, +]; + +module.exports = { + getScratchChunkDir, + getScratchCopyPatterns, + getScratchStaticDir, + mainCopyPatterns, +}; diff --git a/config/devServerSecurity.js b/config/devServerSecurity.js new file mode 100644 index 000000000..b4371e9b3 --- /dev/null +++ b/config/devServerSecurity.js @@ -0,0 +1,37 @@ +const crossOriginHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", + "Access-Control-Allow-Headers": + "X-Requested-With, content-type, Authorization", + // Pyodide input and interruption need cross-origin isolation on the host app. + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", +}; + +const crossOriginResourcePolicyExactPaths = [ + "/pyodide/shims/_internal_sense_hat.js", + "/pyodide/shims/pygal.js", + "/PyodideWorker.js", +]; + +const crossOriginResourcePolicyPrefixes = [ + "/scratch.html", + "/html-renderer.html", +]; + +const shouldSetCrossOriginResourcePolicy = (url = "") => + crossOriginResourcePolicyExactPaths.includes(url) || + crossOriginResourcePolicyPrefixes.some((prefix) => url.startsWith(prefix)); + +const setCrossOriginResourcePolicy = (req, res, next) => { + if (shouldSetCrossOriginResourcePolicy(req.url)) { + res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + } + next(); +}; + +module.exports = { + crossOriginHeaders, + setCrossOriginResourcePolicy, + shouldSetCrossOriginResourcePolicy, +}; diff --git a/docs/ViteMigration.md b/docs/ViteMigration.md new file mode 100644 index 000000000..df27898a1 --- /dev/null +++ b/docs/ViteMigration.md @@ -0,0 +1,13 @@ +# Vite migration notes + +## Test runner scope + +Keep Jest as the test runner for the initial Vite bundler migration. + +Jest is already wired into local scripts, CI coverage reporting, React test +setup, CSS/SVG transforms, and worker mocks. Migrating to Vitest at the same +time would make bundler regressions harder to identify because test +infrastructure changes would be mixed with runtime build changes. + +After the Vite build is deployed, Vitest can be evaluated as a separate change +with its own coverage and transform parity checks. diff --git a/package.json b/package.json index 73aa2b06e..fe2168bab 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "scripts": { "start": "NODE_ENV=development BABEL_ENV=development webpack serve -c ./webpack.config.js", "build": "NODE_ENV=production BABEL_ENV=production webpack build -c ./webpack.config.js", + "check-build-artifacts": "node scripts/check-build-artifacts.js", "analyze": "ANALYZE_WEBPACK_BUNDLE=true yarn build", "lint": "eslint 'src/**/*.{js,jsx}' cypress/**/*.js", "lint:fix": "eslint --fix 'src/**/*.{js,jsx}' cypress/**/*.js", @@ -198,6 +199,8 @@ "modulePaths": [], "moduleNameMapper": { "^react-native$": "react-native-web", + "^(.+\\.md)\\?raw$": "$1", + "^(.+\\.(css|sass|scss))\\?inline$": "$1", "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy" }, "moduleFileExtensions": [ diff --git a/scripts/check-build-artifacts.js b/scripts/check-build-artifacts.js new file mode 100644 index 000000000..3bde74291 --- /dev/null +++ b/scripts/check-build-artifacts.js @@ -0,0 +1,44 @@ +const fs = require("fs"); +const path = require("path"); + +const buildDir = path.resolve(__dirname, "..", "build"); + +const requiredFiles = [ + "web-component.html", + "web-component.js", + "html-renderer.html", + "html-renderer.js", + "scratch.html", + "scratch.js", + "PyodideWorker.js", + "translations/en.json", + "translations/xx-XX.json", + "projects/blank-html-starter.json", + "projects/cool-python.json", + "pyodide/shims/_internal_sense_hat.js", + "pyodide/shims/pygal.js", + "shims/processing/p5/p5-shim.js", + "shims/sense_hat/sense_hat_blob.py", + "python-error-copydecks/en/copydeck.json", + "scratch-gui/static/assets", + "chunks", + "vendor/react.production.min.js", + "vendor/react-dom.production.min.js", + "vendor/redux.min.js", + "vendor/react-redux.min.js", + "vendor/scratch-gui.js", +]; + +const missing = requiredFiles.filter( + (relativePath) => !fs.existsSync(path.join(buildDir, relativePath)), +); + +if (missing.length > 0) { + console.error("Missing required build artifacts:"); + missing.forEach((relativePath) => console.error(`- ${relativePath}`)); + process.exit(1); +} + +console.log( + `Found ${requiredFiles.length} required build artifacts in ${buildDir}.`, +); diff --git a/src/PyodideWorker.js b/src/PyodideWorker.js index 42d8fe27a..3cb08bdfe 100644 --- a/src/PyodideWorker.js +++ b/src/PyodideWorker.js @@ -1,12 +1,11 @@ /* global globalThis, importScripts, loadPyodide, SharedArrayBuffer, Atomics, pygal, _internal_sense_hat */ +import { assetPath } from "./utils/runtimeConfig"; // Nest the PyodideWorker function inside a globalThis object so we control when its initialised. const PyodideWorker = () => { // Import scripts dynamically based on the environment - importScripts( - `${process.env.ASSETS_URL}/pyodide/shims/_internal_sense_hat.js`, - ); - importScripts(`${process.env.ASSETS_URL}/pyodide/shims/pygal.js`); + importScripts(assetPath("pyodide/shims/_internal_sense_hat.js")); + importScripts(assetPath("pyodide/shims/pygal.js")); importScripts("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js"); const supportsAllFeatures = typeof SharedArrayBuffer !== "undefined"; @@ -202,7 +201,7 @@ const PyodideWorker = () => { enigma: { before: async () => { await pyodide.loadPackage( - `${process.env.ASSETS_URL}/pyodide/packages/py_enigma-0.1-py3-none-any.whl`, + assetPath("pyodide/packages/py_enigma-0.1-py3-none-any.whl"), ); }, after: () => {}, @@ -211,7 +210,7 @@ const PyodideWorker = () => { before: async () => { pyodide.registerJsModule("basthon", fakeBasthonPackage); await pyodide.loadPackage( - `${process.env.ASSETS_URL}/pyodide/packages/turtle-0.0.1-py3-none-any.whl`, + assetPath("pyodide/packages/turtle-0.0.1-py3-none-any.whl"), ); }, after: () => @@ -229,7 +228,7 @@ const PyodideWorker = () => { pyodide.registerJsModule("basthon", fakeBasthonPackage); await pyodide.loadPackage([ "setuptools", - `${process.env.ASSETS_URL}/pyodide/packages/p5-0.0.1-py3-none-any.whl`, + assetPath("pyodide/packages/p5-0.0.1-py3-none-any.whl"), ]); }, after: () => {}, @@ -250,7 +249,7 @@ const PyodideWorker = () => { }); await pyodide.loadPackage([ "pillow", - `${process.env.ASSETS_URL}/pyodide/packages/sense_hat-0.0.1-py3-none-any.whl`, + assetPath("pyodide/packages/sense_hat-0.0.1-py3-none-any.whl"), ]); _internal_sense_hat.config.pyodide = pyodide; diff --git a/src/components/AstroPiModel/FlightCase.jsx b/src/components/AstroPiModel/FlightCase.jsx index 52aaa487d..3eea3f850 100644 --- a/src/components/AstroPiModel/FlightCase.jsx +++ b/src/components/AstroPiModel/FlightCase.jsx @@ -3,11 +3,10 @@ import * as THREE from "three"; import { useGLTF } from "@react-three/drei"; import Sk from "skulpt"; import { invalidate } from "@react-three/fiber"; +import { assetPath } from "../../utils/runtimeConfig"; const FlightCase = () => { - const { scene } = useGLTF( - `${process.env.ASSETS_URL}/models/raspi-compressed.glb`, - ); + const { scene } = useGLTF(assetPath("models/raspi-compressed.glb")); window.mod = scene; const blankLED = new THREE.MeshStandardMaterial({ color: `rgb(0,0,0)` }); diff --git a/src/components/Editor/Project/ScratchContainer.jsx b/src/components/Editor/Project/ScratchContainer.jsx index 7c5748843..8de2df0e6 100644 --- a/src/components/Editor/Project/ScratchContainer.jsx +++ b/src/components/Editor/Project/ScratchContainer.jsx @@ -9,6 +9,7 @@ import { postMessageToScratchIframe, getScratchAllowedOrigin, } from "../../../utils/scratchIframe"; +import { assetPath } from "../../../utils/runtimeConfig"; const SCRATCH_MIN_WIDTH = 1024; const SCRATCH_SCROLLBAR_OPTIONS = { @@ -126,9 +127,7 @@ export default function ScratchContainer() { queryParams.set("scratchMetadata", "1"); queryParams.set("parent_origin", window.location.origin); - const iframeSrcUrl = `${ - process.env.ASSETS_URL - }/scratch.html?${queryParams.toString()}`; + const iframeSrcUrl = assetPath(`scratch.html?${queryParams.toString()}`); return (
diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx index c575bbf46..4bc3bc3fa 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx @@ -6,13 +6,14 @@ import { allowedInternalLinks, matchingRegexes, } from "../../../../utils/externalLinkHelper"; -import htmlRunnerStyles from "../../../../assets/stylesheets/HtmlRunner.scss"; +import htmlRunnerStyles from "../../../../assets/stylesheets/HtmlRunner.scss?inline"; import { allowedIframeHost, MSG_HTML_PREVIEW_EVENT, MSG_HTML_PREVIEW_READY, MSG_HTML_PROJECT_UPDATE, } from "../../../../utils/iframeUtils"; +import { getHtmlRendererUrl } from "../../../../utils/runtimeConfig"; const parentTag = (node, tag) => node.parentNode?.tagName && node.parentNode.tagName.toLowerCase() === tag; @@ -39,6 +40,7 @@ const getBlobURL = (code, type) => { const replaceHrefNodes = (indexPage, projectMedia, projectCode) => { const hrefNodes = indexPage.querySelectorAll("[href]"); + const htmlRendererUrl = getHtmlRendererUrl(); hrefNodes.forEach((hrefNode) => { const projectFile = projectCode.find( @@ -61,7 +63,7 @@ const replaceHrefNodes = (indexPage, projectMedia, projectCode) => { } else { // eslint-disable-next-line no-script-url hrefNode.setAttribute("href", "javascript:void(0)"); - onClick = `window.parent.postMessage({type: '${MSG_HTML_PREVIEW_EVENT}', msg: 'RELOAD', payload: { linkTo: '${projectFile.name}' }}, '${process.env.HTML_RENDERER_URL}')`; + onClick = `window.parent.postMessage({type: '${MSG_HTML_PREVIEW_EVENT}', msg: 'RELOAD', payload: { linkTo: '${projectFile.name}' }}, '${htmlRendererUrl}')`; } } else { const matchingExternalHref = matchingRegexes( @@ -79,9 +81,9 @@ const replaceHrefNodes = (indexPage, projectMedia, projectCode) => { ) { // eslint-disable-next-line no-script-url hrefNode.setAttribute("href", "javascript:void(0)"); - onClick = `window.parent.postMessage({type: '${MSG_HTML_PREVIEW_EVENT}', msg: 'ERROR: External link'}, '${process.env.HTML_RENDERER_URL}')`; + onClick = `window.parent.postMessage({type: '${MSG_HTML_PREVIEW_EVENT}', msg: 'ERROR: External link'}, '${htmlRendererUrl}')`; } else if (matchingExternalHref) { - onClick = `window.parent.postMessage({type: '${MSG_HTML_PREVIEW_EVENT}', msg: 'Allowed external link', payload: { linkTo: '${hrefNode.attrs.href}' }}, '${process.env.HTML_RENDERER_URL}')`; + onClick = `window.parent.postMessage({type: '${MSG_HTML_PREVIEW_EVENT}', msg: 'Allowed external link', payload: { linkTo: '${hrefNode.attrs.href}' }}, '${htmlRendererUrl}')`; } } diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.js b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.js index 9a7f2ca3e..90441bdab 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.js +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.js @@ -6,6 +6,7 @@ import { MSG_HTML_PREVIEW_READY, MSG_HTML_PROJECT_UPDATE, } from "../../../../utils/iframeUtils"; +import { getHtmlRendererUrl } from "../../../../utils/runtimeConfig"; let mockMediaQuery = (query) => { return matchMedia(query).matches; @@ -85,7 +86,10 @@ const forbiddenExternalLinkHTMLPage = { }; describe("When run is triggered", () => { + const originalHtmlRendererUrl = process.env.HTML_RENDERER_URL; + beforeEach(async () => { + process.env.HTML_RENDERER_URL = "https://renderer.example.com"; let ready = false; const onMessage = (event) => { @@ -104,6 +108,10 @@ describe("When run is triggered", () => { window.removeEventListener("message", onMessage); }); + afterEach(() => { + process.env.HTML_RENDERER_URL = originalHtmlRendererUrl; + }); + describe("When basic HTML is rendered", () => { beforeEach(() => { window.postMessage( @@ -147,7 +155,7 @@ describe("When run is triggered", () => { ' { ' { ' { ' state.editor.project); @@ -200,7 +204,7 @@ function HtmlRunner() { media: projectMedia, current: indexPage.toString(), }, - process.env.HTML_RENDERER_URL, + getHtmlRendererUrl(), ); if (codeRunTriggered) { @@ -260,7 +264,7 @@ function HtmlRunner() { id="output-frame" title={t("runners.HtmlOutput")} ref={output} - src={`${process.env.HTML_RENDERER_URL}/html-renderer.html`} + src={htmlRendererPath("html-renderer.html")} onLoad={() => { setExternalLink(null); }} diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index 69e7e8cea..fb727a95f 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -27,18 +27,8 @@ import VisualOutputPane from "./VisualOutputPane"; import OutputViewToggle from "../OutputViewToggle"; import { SettingsContext } from "../../../../../utils/settings"; import RunnerControls from "../../../../RunButton/RunnerControls"; - -const getWorkerURL = (url) => { - const content = ` - /* global PyodideWorker */ - console.log("Worker loading"); - importScripts("${url}"); - const pyodide = PyodideWorker(); - console.log("Worker loaded"); - `; - const blob = new Blob([content], { type: "application/javascript" }); - return URL.createObjectURL(blob); -}; +import { publicPath } from "../../../../../utils/runtimeConfig"; +import { createPyodideWorkerUrl } from "../../../../../utils/pyodideWorkerUrl"; const PyodideRunner = ({ active, @@ -80,9 +70,7 @@ const PyodideRunner = ({ useEffect(() => { if (active) { - const workerUrl = getWorkerURL( - `${process.env.PUBLIC_URL}/PyodideWorker.js`, - ); + const workerUrl = createPyodideWorkerUrl(); const worker = new Worker(workerUrl); setPyodideWorker(worker); } @@ -92,7 +80,7 @@ const PyodideRunner = ({ if (friendlyErrorsEnabled) { try { loadCopydeckFor(i18n.language, { - base: `${process.env.PUBLIC_URL}/python-error-copydecks/`, + base: publicPath("python-error-copydecks/"), }); registerAdapter("pyodide", cpythonAdapter); } catch { diff --git a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx index 2a0bf630f..8eadc57cb 100644 --- a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx @@ -33,34 +33,35 @@ import RunnerControls from "../../../../RunButton/RunnerControls"; import { MOBILE_MEDIA_QUERY } from "../../../../../utils/mediaQueryBreakpoints"; import { getPythonImports } from "../../../../../utils/getPythonImports"; import { configureTurtleGraphics } from "../../../../../utils/configureTurtleGraphics"; +import { assetPath, publicPath } from "../../../../../utils/runtimeConfig"; const externalLibraries = { "./pygal/__init__.js": { - path: `${process.env.ASSETS_URL}/shims/pygal/pygal.js`, + path: assetPath("shims/pygal/pygal.js"), dependencies: [ "https://cdnjs.cloudflare.com/ajax/libs/highcharts/6.0.2/highcharts.js", "https://cdnjs.cloudflare.com/ajax/libs/highcharts/6.0.2/js/highcharts-more.js", ], }, "./py5/__init__.js": { - path: `${process.env.ASSETS_URL}/shims/processing/py5/py5-shim.js`, - dependencies: [`${process.env.ASSETS_URL}/libraries/processing/p5/p5.js`], + path: assetPath("shims/processing/py5/py5-shim.js"), + dependencies: [assetPath("libraries/processing/p5/p5.js")], }, "./py5_imported/__init__.js": { - path: `${process.env.ASSETS_URL}/shims/processing/py5_imported_mode/py5_imported.js`, + path: assetPath("shims/processing/py5_imported_mode/py5_imported.js"), }, "./py5_imported_mode.py": { - path: `${process.env.ASSETS_URL}/shims/processing/py5_imported_mode/py5_imported_mode.py`, + path: assetPath("shims/processing/py5_imported_mode/py5_imported_mode.py"), }, "./p5/__init__.js": { - path: `${process.env.ASSETS_URL}/shims/processing/p5/p5-shim.js`, - dependencies: [`${process.env.ASSETS_URL}/libraries/processing/p5/p5.js`], + path: assetPath("shims/processing/p5/p5-shim.js"), + dependencies: [assetPath("libraries/processing/p5/p5.js")], }, "./_internal_sense_hat/__init__.js": { - path: `${process.env.ASSETS_URL}/shims/sense_hat/_internal_sense_hat.js`, + path: assetPath("shims/sense_hat/_internal_sense_hat.js"), }, "./sense_hat.py": { - path: `${process.env.ASSETS_URL}/shims/sense_hat/sense_hat_blob.py`, + path: assetPath("shims/sense_hat/sense_hat_blob.py"), }, }; @@ -177,7 +178,7 @@ const SkulptRunner = ({ if (friendlyErrorsEnabled) { try { loadCopydeckFor(i18n.language, { - base: `${process.env.PUBLIC_URL}/python-error-copydecks/`, + base: publicPath("python-error-copydecks/"), }); registerAdapter("skulpt", cpythonAdapter); } catch { diff --git a/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.jsx b/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.jsx index 7559b1e5b..1631eaab3 100644 --- a/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.jsx +++ b/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.jsx @@ -7,7 +7,7 @@ import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; import SidebarPanel from "../SidebarPanel"; import Prism from "prismjs"; -import demoInstructions from "../../../../assets/markdown/demoInstructions.md"; +import demoInstructions from "../../../../assets/markdown/demoInstructions.md?raw"; import "../../../../assets/stylesheets/Instructions.scss"; import { quizReadyEvent } from "../../../../events/WebComponentCustomEvents"; import { setProjectInstructions } from "../../../../redux/EditorSlice"; diff --git a/src/components/ScratchEditor/ScratchEditor.jsx b/src/components/ScratchEditor/ScratchEditor.jsx index 8ce149903..bf793e8dc 100644 --- a/src/components/ScratchEditor/ScratchEditor.jsx +++ b/src/components/ScratchEditor/ScratchEditor.jsx @@ -3,10 +3,11 @@ import { useCallback, useRef, useEffect, useState } from "react"; import WrapperdScratchGui from "./WrappedScratchGui.jsx"; import { postScratchGuiEvent, allowedParentOrigin } from "./events.js"; +import { assetPath } from "../../utils/runtimeConfig"; +import scratchTemplateConfig from "../../utils/scratchTemplateConfig.cjs"; -/** Scratch library picker assets (not project save/load — those use editor-api). */ export const SCRATCH_LIBRARY_ASSET_URL_TEMPLATE = - "https://editor-assets.raspberrypi.org/internalapi/asset/{assetPath}/get/"; + scratchTemplateConfig.scratchLibraryAssetUrlTemplate; const handleUpdateProjectId = (updatedProjectId) => { postScratchGuiEvent("scratch-gui-project-id-updated", { @@ -84,7 +85,7 @@ const ScratchEditor = ({ projectHost={`${apiUrl}/api/scratch/projects`} assetHost={`${apiUrl}/api/scratch/assets`} libraryAssetUrlTemplate={SCRATCH_LIBRARY_ASSET_URL_TEMPLATE} - basePath={`${process.env.ASSETS_URL}/scratch-gui/`} + basePath={assetPath("scratch-gui/")} onStorageInit={(storage) => { scratchFetchApiRef.current = storage.scratchFetch; if (accessToken) { diff --git a/src/containers/WebComponentLoader.jsx b/src/containers/WebComponentLoader.jsx index 0aef37da2..f6f654afb 100644 --- a/src/containers/WebComponentLoader.jsx +++ b/src/containers/WebComponentLoader.jsx @@ -27,15 +27,16 @@ import ToastCloseButton from "../utils/ToastCloseButton"; import Loader from "../components/Loader/Loader"; import LoadFailed from "../components/LoadFailed/LoadFailed"; -import internalStyles from "../assets/stylesheets/InternalStyles.scss"; -import externalStyles from "../assets/stylesheets/ExternalStyles.scss"; -import editorStyles from "../assets/stylesheets/index.scss"; +import internalStyles from "../assets/stylesheets/InternalStyles.scss?inline"; +import externalStyles from "../assets/stylesheets/ExternalStyles.scss?inline"; +import editorStyles from "../assets/stylesheets/index.scss?inline"; import "../assets/stylesheets/Notifications.scss"; import Style from "style-it"; import { projectLoadFailed, projectOwnerLoadedEvent, } from "../events/WebComponentCustomEvents"; +import { getRuntimeEnv } from "../utils/runtimeConfig"; const CODE_EDITOR_FEEDBACK_URL = "https://form.raspberrypi.org/f/code-editor-feedback"; @@ -59,8 +60,8 @@ const WebComponentLoader = (props) => { outputSplitView = false, sidebarPlugins = [], projectNameEditable = false, - reactAppApiEndpoint = process.env.REACT_APP_API_ENDPOINT, - scratchApiEndpoint = process.env.REACT_APP_API_ENDPOINT, + reactAppApiEndpoint = getRuntimeEnv("REACT_APP_API_ENDPOINT"), + scratchApiEndpoint = getRuntimeEnv("REACT_APP_API_ENDPOINT"), readOnly = false, senseHatAlwaysEnabled = false, friendlyErrorsEnabled = false, diff --git a/src/scratch.jsx b/src/scratch.jsx index f93b9c8d0..af7b9f14a 100644 --- a/src/scratch.jsx +++ b/src/scratch.jsx @@ -1,9 +1,9 @@ import React from "react"; import { createRoot } from "react-dom/client"; -import process from "process"; import dedupeScratchWarnings from "./utils/dedupeScratchWarnings.js"; +import { isProduction } from "./utils/runtimeConfig.js"; -import ScratchStyles from "./assets/stylesheets/Scratch.scss"; +import ScratchStyles from "./assets/stylesheets/Scratch.scss?inline"; import ScratchEditor from "./components/ScratchEditor/ScratchEditor.jsx"; import { postScratchGuiEvent, @@ -14,7 +14,7 @@ dedupeScratchWarnings(); const appTarget = document.getElementById("app"); const scratchLoading = document.getElementById("scratch-loading"); -if (process.env.NODE_ENV === "production" && typeof window === "object") { +if (isProduction() && typeof window === "object") { // Warn before navigating away window.onbeforeunload = () => true; } diff --git a/src/utils/dedupeDesignSystemWarnings.js b/src/utils/dedupeDesignSystemWarnings.js index 0f8dfda7a..588245a6d 100644 --- a/src/utils/dedupeDesignSystemWarnings.js +++ b/src/utils/dedupeDesignSystemWarnings.js @@ -1,10 +1,12 @@ +import { isDevelopment } from "./runtimeConfig"; + const designSystemWarningsKey = "__designSystemWarningsDeduped"; const designSystemIconWarning = "DEPRECATED: icons as React elements will not be supported in future releases"; const dedupeDesignSystemWarnings = () => { if ( - process.env.NODE_ENV !== "development" || + !isDevelopment() || typeof window !== "object" || window[designSystemWarningsKey] ) { diff --git a/src/utils/dedupeScratchWarnings.js b/src/utils/dedupeScratchWarnings.js index 5a372ef1b..44075b2a7 100644 --- a/src/utils/dedupeScratchWarnings.js +++ b/src/utils/dedupeScratchWarnings.js @@ -1,3 +1,5 @@ +import { isDevelopment } from "./runtimeConfig"; + const scratchWarningsKey = "__scratchWarningsDeduped"; const scratchWarningMatchers = { @@ -33,7 +35,7 @@ const scratchWarningMatchers = { const dedupeScratchWarnings = () => { if ( - process.env.NODE_ENV !== "development" || + !isDevelopment() || typeof window !== "object" || window[scratchWarningsKey] ) { diff --git a/src/utils/externalLinkHelper.js b/src/utils/externalLinkHelper.js index f21f4d6d4..370464cfd 100644 --- a/src/utils/externalLinkHelper.js +++ b/src/utils/externalLinkHelper.js @@ -2,9 +2,10 @@ import { useState } from "react"; import { useDispatch } from "react-redux"; import { setError, triggerCodeRun } from "../redux/EditorSlice"; +import { getPublicUrl } from "./runtimeConfig"; const domain = "https://rpf.io/"; -const host = process.env.PUBLIC_URL || "http://localhost:3011"; +const host = getPublicUrl() || "http://localhost:3011"; const rpfDomain = new RegExp(`^${domain}`); const hostDomain = new RegExp(`^${host}`); const allowedInternalLinks = [new RegExp(`^#[a-zA-Z0-9]+`)]; diff --git a/src/utils/i18n.js b/src/utils/i18n.js index 7525f2a61..757331b34 100644 --- a/src/utils/i18n.js +++ b/src/utils/i18n.js @@ -1,6 +1,7 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import HttpBackend from "i18next-http-backend"; +import { publicPath } from "./runtimeConfig"; i18n // pass the i18n instance to react-i18next. @@ -105,7 +106,7 @@ i18n escapeValue: false, // not needed for react!! }, backend: { - loadPath: `${process.env.PUBLIC_URL}/translations/{{lng}}.json`, + loadPath: publicPath("translations/{{lng}}.json"), }, }); diff --git a/src/utils/iframeUtils.js b/src/utils/iframeUtils.js index bfe88beae..42b31199a 100644 --- a/src/utils/iframeUtils.js +++ b/src/utils/iframeUtils.js @@ -1,8 +1,13 @@ +import { getRuntimeEnv, isTest } from "./runtimeConfig"; + +const isSameOrigin = (origin) => + typeof window !== "undefined" && origin === window.location.origin; + export function allowedIframeHost(origin) { - const allowedHosts = process.env.REACT_APP_ALLOWED_IFRAME_ORIGINS - ? process.env.REACT_APP_ALLOWED_IFRAME_ORIGINS.split(",") - : []; - return process.env.NODE_ENV === "test" || allowedHosts.includes(origin); + const allowedHosts = getRuntimeEnv("REACT_APP_ALLOWED_IFRAME_ORIGINS") + .split(",") + .filter(Boolean); + return isTest() || isSameOrigin(origin) || allowedHosts.includes(origin); } export const MSG_HTML_PREVIEW_READY = "editor-html-ready"; diff --git a/src/utils/iframeUtils.test.js b/src/utils/iframeUtils.test.js new file mode 100644 index 000000000..e4353c2f6 --- /dev/null +++ b/src/utils/iframeUtils.test.js @@ -0,0 +1,31 @@ +import { allowedIframeHost } from "./iframeUtils"; + +describe("allowedIframeHost", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv, NODE_ENV: "development" }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("allows same-origin messages without env configuration", () => { + process.env.REACT_APP_ALLOWED_IFRAME_ORIGINS = ""; + + expect(allowedIframeHost(window.location.origin)).toBe(true); + }); + + it("allows configured iframe origins", () => { + process.env.REACT_APP_ALLOWED_IFRAME_ORIGINS = "https://editor.example.com"; + + expect(allowedIframeHost("https://editor.example.com")).toBe(true); + }); + + it("rejects unrelated origins", () => { + process.env.REACT_APP_ALLOWED_IFRAME_ORIGINS = "https://editor.example.com"; + + expect(allowedIframeHost("https://other.example.com")).toBe(false); + }); +}); diff --git a/src/utils/pyodideWorkerUrl.js b/src/utils/pyodideWorkerUrl.js new file mode 100644 index 000000000..6c16c1ee4 --- /dev/null +++ b/src/utils/pyodideWorkerUrl.js @@ -0,0 +1,23 @@ +import { publicOriginPath } from "./runtimeConfig"; + +export const PYODIDE_WORKER_ARTIFACT = "PyodideWorker.js"; + +export const getPyodideWorkerScriptUrl = () => + publicOriginPath(PYODIDE_WORKER_ARTIFACT); + +export const getPyodideWorkerBootstrap = (workerScriptUrl) => ` + /* global PyodideWorker */ + console.log("Worker loading"); + importScripts("${workerScriptUrl}"); + const pyodide = PyodideWorker(); + console.log("Worker loaded"); + `; + +export const createPyodideWorkerUrl = ( + workerScriptUrl = getPyodideWorkerScriptUrl(), +) => { + const blob = new Blob([getPyodideWorkerBootstrap(workerScriptUrl)], { + type: "application/javascript", + }); + return URL.createObjectURL(blob); +}; diff --git a/src/utils/pyodideWorkerUrl.test.js b/src/utils/pyodideWorkerUrl.test.js new file mode 100644 index 000000000..e5740ce98 --- /dev/null +++ b/src/utils/pyodideWorkerUrl.test.js @@ -0,0 +1,52 @@ +import { + createPyodideWorkerUrl, + getPyodideWorkerBootstrap, + getPyodideWorkerScriptUrl, +} from "./pyodideWorkerUrl"; + +describe("pyodideWorkerUrl", () => { + const originalPublicUrl = process.env.PUBLIC_URL; + const originalBlob = global.Blob; + const originalCreateObjectUrl = URL.createObjectURL; + + afterEach(() => { + process.env.PUBLIC_URL = originalPublicUrl; + global.Blob = originalBlob; + URL.createObjectURL = originalCreateObjectUrl; + }); + + it("keeps the stable worker artifact path under PUBLIC_URL", () => { + process.env.PUBLIC_URL = "."; + + expect(getPyodideWorkerScriptUrl()).toBe("./PyodideWorker.js"); + }); + + it("uses the browser origin for blob worker imports when PUBLIC_URL is blank", () => { + process.env.PUBLIC_URL = ""; + + expect(getPyodideWorkerScriptUrl()).toBe( + `${window.location.origin}/PyodideWorker.js`, + ); + }); + + it("creates the worker bootstrap that imports the emitted artifact", () => { + expect(getPyodideWorkerBootstrap("/PyodideWorker.js")).toContain( + 'importScripts("/PyodideWorker.js");', + ); + }); + + it("wraps the bootstrap in a JavaScript blob URL", () => { + const blob = {}; + global.Blob = jest.fn(() => blob); + URL.createObjectURL = jest.fn(() => "blob:pyodide-worker"); + + expect(createPyodideWorkerUrl("/PyodideWorker.js")).toBe( + "blob:pyodide-worker", + ); + expect(global.Blob).toHaveBeenCalledWith( + [expect.stringContaining('importScripts("/PyodideWorker.js");')], + { type: "application/javascript" }, + ); + expect(URL.createObjectURL).toHaveBeenCalledWith(blob); + }); +}); diff --git a/src/utils/runtimeConfig.js b/src/utils/runtimeConfig.js new file mode 100644 index 000000000..6d969bffb --- /dev/null +++ b/src/utils/runtimeConfig.js @@ -0,0 +1,56 @@ +/* global __RUNTIME_ENV__, globalThis */ + +const trimLeadingSlash = (value) => value.replace(/^\/+/, ""); + +const getBundledEnv = () => + typeof __RUNTIME_ENV__ === "undefined" ? {} : __RUNTIME_ENV__; + +export const getRuntimeEnv = (name, fallback = "") => { + const bundledValue = getBundledEnv()[name]; + if (bundledValue !== undefined) return bundledValue; + + const env = typeof process === "undefined" ? {} : process.env || {}; + const value = env[name]; + + return value === undefined ? fallback : value; +}; + +export const getNodeEnv = () => getRuntimeEnv("NODE_ENV", "development"); + +export const isDevelopment = () => getNodeEnv() === "development"; + +export const isProduction = () => getNodeEnv() === "production"; + +export const isTest = () => getNodeEnv() === "test"; + +export const getPublicUrl = () => getRuntimeEnv("PUBLIC_URL", ""); + +export const getRuntimeOrigin = () => { + if (typeof globalThis === "undefined") return ""; + return globalThis.location?.origin || ""; +}; + +export const getPublicOriginUrl = () => getPublicUrl() || getRuntimeOrigin(); + +export const getAssetsUrl = () => + getRuntimeEnv("ASSETS_URL") || getPublicUrl() || getRuntimeOrigin(); + +export const getHtmlRendererUrl = () => + getRuntimeEnv("HTML_RENDERER_URL") || getPublicUrl() || getRuntimeOrigin(); + +export const runtimeUrl = (baseUrl, path) => { + const normalizedPath = trimLeadingSlash(path); + if (!baseUrl) return `/${normalizedPath}`; + if (baseUrl.endsWith("/")) return `${baseUrl}${normalizedPath}`; + return `${baseUrl}/${normalizedPath}`; +}; + +export const publicPath = (path) => runtimeUrl(getPublicUrl(), path); + +export const publicOriginPath = (path) => + runtimeUrl(getPublicOriginUrl(), path); + +export const assetPath = (path) => runtimeUrl(getAssetsUrl(), path); + +export const htmlRendererPath = (path) => + runtimeUrl(getHtmlRendererUrl(), path); diff --git a/src/utils/runtimeConfig.test.js b/src/utils/runtimeConfig.test.js new file mode 100644 index 000000000..4a2778f63 --- /dev/null +++ b/src/utils/runtimeConfig.test.js @@ -0,0 +1,97 @@ +import { + assetPath, + getAssetsUrl, + getHtmlRendererUrl, + getPublicOriginUrl, + getRuntimeOrigin, + getRuntimeEnv, + htmlRendererPath, + publicOriginPath, + publicPath, +} from "./runtimeConfig"; + +describe("runtimeConfig", () => { + const originalEnv = process.env; + const originalBundledEnv = global.__RUNTIME_ENV__; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + global.__RUNTIME_ENV__ = originalBundledEnv; + }); + + it("reads environment values dynamically", () => { + process.env.REACT_APP_API_ENDPOINT = "https://api.example.com"; + + expect(getRuntimeEnv("REACT_APP_API_ENDPOINT")).toBe( + "https://api.example.com", + ); + }); + + it("prefers bundled environment values when present", () => { + process.env.REACT_APP_API_ENDPOINT = "https://process.example.com"; + global.__RUNTIME_ENV__ = { + REACT_APP_API_ENDPOINT: "https://bundle.example.com", + }; + + expect(getRuntimeEnv("REACT_APP_API_ENDPOINT")).toBe( + "https://bundle.example.com", + ); + }); + + it("falls back to PUBLIC_URL for asset and HTML renderer origins", () => { + process.env.PUBLIC_URL = "https://static.example.com/branch"; + delete process.env.ASSETS_URL; + delete process.env.HTML_RENDERER_URL; + + expect(getAssetsUrl()).toBe("https://static.example.com/branch"); + expect(getHtmlRendererUrl()).toBe("https://static.example.com/branch"); + }); + + it("falls back to the browser origin for empty asset origins", () => { + process.env.PUBLIC_URL = ""; + process.env.ASSETS_URL = ""; + process.env.HTML_RENDERER_URL = ""; + + expect(getAssetsUrl()).toBe(window.location.origin); + expect(getHtmlRendererUrl()).toBe(window.location.origin); + }); + + it("uses globalThis.location as the runtime origin", () => { + expect(getRuntimeOrigin()).toBe(window.location.origin); + }); + + it("joins paths without duplicating slashes", () => { + process.env.PUBLIC_URL = "https://static.example.com/branch/"; + process.env.ASSETS_URL = "https://assets.example.com/branch"; + process.env.HTML_RENDERER_URL = "https://renderer.example.com/branch"; + + expect(publicPath("/translations/en.json")).toBe( + "https://static.example.com/branch/translations/en.json", + ); + expect(assetPath("scratch.html")).toBe( + "https://assets.example.com/branch/scratch.html", + ); + expect(htmlRendererPath("/html-renderer.html")).toBe( + "https://renderer.example.com/branch/html-renderer.html", + ); + }); + + it("keeps root-relative paths when PUBLIC_URL is blank", () => { + process.env.PUBLIC_URL = ""; + + expect(publicPath("translations/en.json")).toBe("/translations/en.json"); + }); + + it("can force browser-origin public paths for blob workers", () => { + process.env.PUBLIC_URL = ""; + + expect(getPublicOriginUrl()).toBe(window.location.origin); + expect(publicOriginPath("PyodideWorker.js")).toBe( + `${window.location.origin}/PyodideWorker.js`, + ); + }); +}); diff --git a/src/utils/scratchIframe.js b/src/utils/scratchIframe.js index 912256f5a..92e68cd40 100644 --- a/src/utils/scratchIframe.js +++ b/src/utils/scratchIframe.js @@ -1,3 +1,5 @@ +import { getAssetsUrl } from "./runtimeConfig"; + export const getScratchIframeContentWindow = () => { const webComponent = document.querySelector("editor-wc"); return webComponent.shadowRoot.querySelector("iframe[title='Scratch']") @@ -6,7 +8,7 @@ export const getScratchIframeContentWindow = () => { export const getScratchAllowedOrigin = () => { const fallbackOrigin = window.location.origin; - const configuredAssetsUrl = process.env.ASSETS_URL; + const configuredAssetsUrl = getAssetsUrl(); if (!configuredAssetsUrl) { return fallbackOrigin; diff --git a/src/utils/scratchTemplateConfig.cjs b/src/utils/scratchTemplateConfig.cjs new file mode 100644 index 000000000..0355a657e --- /dev/null +++ b/src/utils/scratchTemplateConfig.cjs @@ -0,0 +1,53 @@ +const scratchLibraryAssetUrlTemplate = + "https://editor-assets.raspberrypi.org/internalapi/asset/{assetPath}/get/"; + +const toOrigin = (envVarName, value) => { + const normalizedValue = String(value || "") + .trim() + .replace(/^['"]|['"]$/g, ""); + + if (!normalizedValue) return ""; + + try { + return new URL(normalizedValue).origin; + } catch (_) { + throw new Error( + `Invalid URL in ${envVarName}: "${value}". ` + + `Expected an absolute URL, for example "https://example.com".`, + ); + } +}; + +const getCspApiMultipleOrigins = (value) => + String(value || "") + .split(/[\s,]+/) + .map((originValue, index) => + toOrigin(`CSP_API_MULTIPLE_ORIGINS[${index}]`, originValue), + ) + .filter(Boolean) + .join(" "); + +const getScratchTemplateParameters = ({ + assetsUrl, + cspApiMultipleOrigins, + nodeEnv, + publicUrl, + reactAppApiEndpoint, +}) => ({ + publicUrl, + cspApiOrigin: toOrigin("REACT_APP_API_ENDPOINT", reactAppApiEndpoint), + cspApiMultipleOrigins: getCspApiMultipleOrigins(cspApiMultipleOrigins), + cspAssetOrigin: toOrigin("ASSETS_URL", assetsUrl), + cspScratchLibraryAssetOrigin: toOrigin( + "SCRATCH_LIBRARY_ASSET_URL_TEMPLATE", + scratchLibraryAssetUrlTemplate, + ), + isDev: nodeEnv !== "production", +}); + +module.exports = { + getCspApiMultipleOrigins, + getScratchTemplateParameters, + scratchLibraryAssetUrlTemplate, + toOrigin, +}; diff --git a/src/utils/scratchTemplateConfig.test.js b/src/utils/scratchTemplateConfig.test.js new file mode 100644 index 000000000..d4819e87c --- /dev/null +++ b/src/utils/scratchTemplateConfig.test.js @@ -0,0 +1,55 @@ +const { + getCspApiMultipleOrigins, + getScratchTemplateParameters, + scratchLibraryAssetUrlTemplate, + toOrigin, +} = require("./scratchTemplateConfig.cjs"); + +describe("scratchTemplateConfig", () => { + it("normalizes configured URLs to CSP origins", () => { + expect(toOrigin("ASSETS_URL", "https://example.com/branches/main")).toBe( + "https://example.com", + ); + }); + + it("rejects invalid absolute URLs", () => { + expect(() => toOrigin("ASSETS_URL", "not a url")).toThrow( + 'Invalid URL in ASSETS_URL: "not a url"', + ); + }); + + it("supports comma and whitespace separated API origins", () => { + expect( + getCspApiMultipleOrigins( + "https://api.example.com, https://test.example.com\nhttps://extra.example.com/path", + ), + ).toBe( + "https://api.example.com https://test.example.com https://extra.example.com", + ); + }); + + it("builds Scratch HTML template parameters", () => { + expect( + getScratchTemplateParameters({ + assetsUrl: "https://assets.example.com/branches/main", + cspApiMultipleOrigins: "https://api-one.example.com", + nodeEnv: "production", + publicUrl: "https://static.example.com/releases/v1/", + reactAppApiEndpoint: "https://api.example.com/v1", + }), + ).toEqual({ + publicUrl: "https://static.example.com/releases/v1/", + cspApiOrigin: "https://api.example.com", + cspApiMultipleOrigins: "https://api-one.example.com", + cspAssetOrigin: "https://assets.example.com", + cspScratchLibraryAssetOrigin: "https://editor-assets.raspberrypi.org", + isDev: false, + }); + }); + + it("exports the Scratch library asset template used at runtime", () => { + expect(scratchLibraryAssetUrlTemplate).toBe( + "https://editor-assets.raspberrypi.org/internalapi/asset/{assetPath}/get/", + ); + }); +}); diff --git a/src/utils/setupTests.js b/src/utils/setupTests.js index 6ccf273b1..f63b501b0 100644 --- a/src/utils/setupTests.js +++ b/src/utils/setupTests.js @@ -48,7 +48,7 @@ jest.mock("./i18n", () => ({ t: (string) => string, })); -jest.mock("../assets/markdown/demoInstructions.md", () => { +jest.mock("../assets/markdown/demoInstructions.md?raw", () => { return "demoInstructions.md"; }); diff --git a/src/utils/userManager.js b/src/utils/userManager.js index 210a56764..df0a8005f 100644 --- a/src/utils/userManager.js +++ b/src/utils/userManager.js @@ -1,12 +1,13 @@ import { createUserManager } from "redux-oidc"; import { WebStorageStateStore } from "oidc-client"; +import { getRuntimeEnv } from "./runtimeConfig"; const host = `${window.location.protocol}//${window.location.hostname}${ window.location.port ? `:${window.location.port}` : "" }`; const userManagerConfig = ({ reactAppAuthenticationUrl }) => ({ - client_id: process.env.REACT_APP_AUTHENTICATION_CLIENT_ID, + client_id: getRuntimeEnv("REACT_APP_AUTHENTICATION_CLIENT_ID"), redirect_uri: `${host}/auth/callback`, post_logout_redirect_uri: host, response_type: "code", diff --git a/src/web-component.js b/src/web-component.js index 537a4d267..44de0a3ee 100644 --- a/src/web-component.js +++ b/src/web-component.js @@ -13,13 +13,14 @@ import { resetStore } from "./redux/RootSlice"; import dedupeDesignSystemWarnings from "./utils/dedupeDesignSystemWarnings"; import { setUser } from "./redux/WebComponentAuthSlice"; import { projectHasChangedSinceInitialLoad } from "./utils/projectHelpers"; +import { getRuntimeEnv } from "./utils/runtimeConfig"; dedupeDesignSystemWarnings(); Sentry.init({ - dsn: process.env.REACT_APP_SENTRY_DSN, + dsn: getRuntimeEnv("REACT_APP_SENTRY_DSN"), integrations: [new BrowserTracing()], - environment: process.env.REACT_APP_SENTRY_ENV, + environment: getRuntimeEnv("REACT_APP_SENTRY_ENV"), tracesSampleRate: 0.1, }); diff --git a/webpack.config.js b/webpack.config.js index 3c090b227..2428cdf39 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,10 +1,24 @@ const path = require("path"); +const webpack = require("webpack"); const dotenv = require("dotenv"); const Dotenv = require("dotenv-webpack"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const WorkerPlugin = require("worker-plugin"); const CopyWebpackPlugin = require("copy-webpack-plugin"); const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); +const { + getScratchChunkDir, + getScratchCopyPatterns, + getScratchStaticDir, + mainCopyPatterns, +} = require("./config/buildArtifacts"); +const { + crossOriginHeaders, + setCrossOriginResourcePolicy, +} = require("./config/devServerSecurity"); +const { + getScratchTemplateParameters, +} = require("./src/utils/scratchTemplateConfig.cjs"); dotenv.config({ path: path.resolve(__dirname, ".env") }); @@ -12,54 +26,31 @@ let publicUrl = process.env.PUBLIC_URL || "/"; if (!publicUrl.endsWith("/")) { publicUrl += "/"; } -const isDev = process.env.NODE_ENV !== "production"; - -const toOrigin = (envVarName, value) => { - const normalizedValue = String(value || "") - .trim() - .replace(/^['"]|['"]$/g, ""); - - if (!normalizedValue) return ""; - - try { - return new URL(normalizedValue).origin; - } catch (_) { - throw new Error( - `Invalid URL in ${envVarName}: "${value}". ` + - `Expected an absolute URL, for example "https://example.com".`, - ); - } -}; - -const cspApiOrigin = toOrigin( - "REACT_APP_API_ENDPOINT", - process.env.REACT_APP_API_ENDPOINT, -); -const cspAssetOrigin = toOrigin("ASSETS_URL", process.env.ASSETS_URL); - -// Keep in sync with SCRATCH_LIBRARY_ASSET_URL_TEMPLATE in ScratchEditor.jsx -const cspScratchLibraryAssetOrigin = "https://editor-assets.raspberrypi.org"; - -// When present these override cspApiOrigin for CSP API/connect-src origins. -// This supports staging setups that need to allow multiple API origins, -// such as also reaching the test API. -const cspApiMultipleOrigins = String(process.env.CSP_API_MULTIPLE_ORIGINS || "") - .split(/[\s,]+/) - .map((originValue, index) => - toOrigin(`CSP_API_MULTIPLE_ORIGINS[${index}]`, originValue), +const scratchStaticDir = getScratchStaticDir(__dirname); +const scratchChunkDir = getScratchChunkDir(__dirname); +const scratchTemplateParameters = getScratchTemplateParameters({ + assetsUrl: process.env.ASSETS_URL, + cspApiMultipleOrigins: process.env.CSP_API_MULTIPLE_ORIGINS, + nodeEnv: process.env.NODE_ENV, + publicUrl, + reactAppApiEndpoint: process.env.REACT_APP_API_ENDPOINT, +}); +const runtimeEnvValues = Object.keys(process.env) + .filter( + (key) => + key.startsWith("REACT_APP_") || + ["ASSETS_URL", "HTML_RENDERER_URL", "PUBLIC_URL"].includes(key), ) - .filter(Boolean) - .join(" "); - -const scratchStaticDir = path.resolve( - __dirname, - "node_modules/@RaspberryPiFoundation/scratch-gui/dist/static", -); - -const scratchChunkDir = path.resolve( - __dirname, - "node_modules/@RaspberryPiFoundation/scratch-gui/dist/chunks", -); + .reduce( + (values, key) => ({ + ...values, + [key]: process.env[key], + }), + { NODE_ENV: process.env.NODE_ENV || "development" }, + ); +const runtimeEnvPlugin = new webpack.DefinePlugin({ + __RUNTIME_ENV__: JSON.stringify(runtimeEnvValues), +}); const moduleRules = [ { @@ -193,30 +184,9 @@ const mainConfig = { publicPath: `${publicUrl}scratch-gui/chunks`, }, ], - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", - "Access-Control-Allow-Headers": - "X-Requested-With, content-type, Authorization", - // Pyodide - required for input and code interruption - needed on the host app - "Cross-Origin-Opener-Policy": "same-origin", - "Cross-Origin-Embedder-Policy": "require-corp", - }, + headers: crossOriginHeaders, setupMiddlewares: (middlewares, devServer) => { - devServer.app.use((req, res, next) => { - if ( - [ - "/pyodide/shims/_internal_sense_hat.js", - "/pyodide/shims/pygal.js", - "/PyodideWorker.js", - ].includes(req.url) || - req.url.startsWith("/scratch.html") || - req.url.startsWith("/html-renderer.html") - ) { - res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); - } - next(); - }); + devServer.app.use(setCrossOriginResourcePolicy); return middlewares; }, }, @@ -227,6 +197,7 @@ const mainConfig = { path: "./.env", systemvars: true, }), + runtimeEnvPlugin, new HtmlWebpackPlugin({ inject: "body", template: "src/web-component.html", @@ -239,16 +210,7 @@ const mainConfig = { filename: "html-renderer.html", chunks: ["html-renderer"], }), - new CopyWebpackPlugin({ - patterns: [ - { from: "public", to: "" }, - { from: "src/projects", to: "projects" }, - { - from: "node_modules/@raspberrypifoundation/python-friendly-error-messages/copydecks", - to: "python-error-copydecks", - }, - ], - }), + new CopyWebpackPlugin({ patterns: mainCopyPatterns }), ], stats: "minimal", }; @@ -283,50 +245,16 @@ const scratchConfig = { path: "./.env", systemvars: true, }), + runtimeEnvPlugin, new HtmlWebpackPlugin({ inject: "body", template: "src/scratch.html", filename: "scratch.html", chunks: ["scratch"], - templateParameters: { - publicUrl: publicUrl, - cspApiOrigin, - cspApiMultipleOrigins, - cspAssetOrigin, - cspScratchLibraryAssetOrigin, - isDev, - }, + templateParameters: scratchTemplateParameters, }), new CopyWebpackPlugin({ - patterns: [ - { from: scratchStaticDir, to: "scratch-gui/static" }, - { from: `${scratchStaticDir}/assets`, to: "vendor/static/assets" }, - { from: scratchChunkDir, to: "chunks" }, - { - from: "node_modules/scratchReactVendor/umd/react.production.min.js", - to: "vendor/react.production.min.js", - }, - { - from: "node_modules/scratchReactDomVendor/umd/react-dom.production.min.js", - to: "vendor/react-dom.production.min.js", - }, - { - from: "node_modules/redux/dist/redux.min.js", - to: "vendor/redux.min.js", - }, - { - from: "node_modules/react-redux/dist/react-redux.min.js", - to: "vendor/react-redux.min.js", - }, - { - from: "node_modules/@RaspberryPiFoundation/scratch-gui/dist/scratch-gui.js", - to: "vendor/scratch-gui.js", - }, - { - from: "node_modules/@RaspberryPiFoundation/scratch-gui/dist/scratch-gui.js.LICENSE.txt", - to: "vendor/scratch-gui.js.LICENSE.txt", - }, - ], + patterns: getScratchCopyPatterns({ scratchStaticDir, scratchChunkDir }), }), ], stats: "minimal",