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",