From 39014729ae1a81710976fa22593cd8e11c650d27 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 07:52:32 +0000 Subject: [PATCH 01/13] Centralize runtime configuration for Vite prep Move browser-facing environment lookups behind runtime helpers so the upcoming Vite migration only has to adapt one compatibility layer instead of many scattered process.env call sites. Co-authored-by: Chris Zetter --- src/PyodideWorker.js | 15 +++-- src/components/AstroPiModel/FlightCase.jsx | 5 +- .../Editor/Project/ScratchContainer.jsx | 5 +- .../Runners/HtmlRunner/HtmlRenderer.jsx | 8 ++- .../Runners/HtmlRunner/HtmlRenderer.test.js | 16 +++-- .../Editor/Runners/HtmlRunner/HtmlRunner.jsx | 8 ++- .../PyodideRunner/PyodideRunner.jsx | 7 +-- .../SkulptRunner/SkulptRunner.jsx | 25 ++++---- .../ScratchEditor/ScratchEditor.jsx | 3 +- src/containers/WebComponentLoader.jsx | 5 +- src/scratch.jsx | 4 +- src/utils/dedupeDesignSystemWarnings.js | 4 +- src/utils/dedupeScratchWarnings.js | 4 +- src/utils/externalLinkHelper.js | 3 +- src/utils/i18n.js | 3 +- src/utils/iframeUtils.js | 10 ++-- src/utils/runtimeConfig.js | 37 ++++++++++++ src/utils/runtimeConfig.test.js | 59 +++++++++++++++++++ src/utils/scratchIframe.js | 4 +- src/utils/userManager.js | 3 +- src/web-component.js | 5 +- 21 files changed, 179 insertions(+), 54 deletions(-) create mode 100644 src/utils/runtimeConfig.js create mode 100644 src/utils/runtimeConfig.test.js 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..b31820749 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx @@ -13,6 +13,7 @@ import { 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..3771dca97 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -27,6 +27,7 @@ import VisualOutputPane from "./VisualOutputPane"; import OutputViewToggle from "../OutputViewToggle"; import { SettingsContext } from "../../../../../utils/settings"; import RunnerControls from "../../../../RunButton/RunnerControls"; +import { publicPath } from "../../../../../utils/runtimeConfig"; const getWorkerURL = (url) => { const content = ` @@ -80,9 +81,7 @@ const PyodideRunner = ({ useEffect(() => { if (active) { - const workerUrl = getWorkerURL( - `${process.env.PUBLIC_URL}/PyodideWorker.js`, - ); + const workerUrl = getWorkerURL(publicPath("PyodideWorker.js")); const worker = new Worker(workerUrl); setPyodideWorker(worker); } @@ -92,7 +91,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..302842343 100644 --- a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx @@ -33,34 +33,39 @@ 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 +182,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/ScratchEditor/ScratchEditor.jsx b/src/components/ScratchEditor/ScratchEditor.jsx index 8ce149903..0d260ac31 100644 --- a/src/components/ScratchEditor/ScratchEditor.jsx +++ b/src/components/ScratchEditor/ScratchEditor.jsx @@ -3,6 +3,7 @@ import { useCallback, useRef, useEffect, useState } from "react"; import WrapperdScratchGui from "./WrappedScratchGui.jsx"; import { postScratchGuiEvent, allowedParentOrigin } from "./events.js"; +import { assetPath } from "../../utils/runtimeConfig"; /** Scratch library picker assets (not project save/load — those use editor-api). */ export const SCRATCH_LIBRARY_ASSET_URL_TEMPLATE = @@ -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..cffa58b7d 100644 --- a/src/containers/WebComponentLoader.jsx +++ b/src/containers/WebComponentLoader.jsx @@ -36,6 +36,7 @@ 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..e82a093db 100644 --- a/src/scratch.jsx +++ b/src/scratch.jsx @@ -1,7 +1,7 @@ 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 ScratchEditor from "./components/ScratchEditor/ScratchEditor.jsx"; @@ -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..cdca7f2cc 100644 --- a/src/utils/iframeUtils.js +++ b/src/utils/iframeUtils.js @@ -1,8 +1,10 @@ +import { getRuntimeEnv, isTest } from "./runtimeConfig"; + 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() || allowedHosts.includes(origin); } export const MSG_HTML_PREVIEW_READY = "editor-html-ready"; diff --git a/src/utils/runtimeConfig.js b/src/utils/runtimeConfig.js new file mode 100644 index 000000000..c2b9960d5 --- /dev/null +++ b/src/utils/runtimeConfig.js @@ -0,0 +1,37 @@ +const trimLeadingSlash = (value) => value.replace(/^\/+/, ""); + +export const getRuntimeEnv = (name, fallback = "") => { + 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 getAssetsUrl = () => getRuntimeEnv("ASSETS_URL", getPublicUrl()); + +export const getHtmlRendererUrl = () => + getRuntimeEnv("HTML_RENDERER_URL", getPublicUrl()); + +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 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..23f2e1e23 --- /dev/null +++ b/src/utils/runtimeConfig.test.js @@ -0,0 +1,59 @@ +import { + assetPath, + getAssetsUrl, + getHtmlRendererUrl, + getRuntimeEnv, + htmlRendererPath, + publicPath, +} from "./runtimeConfig"; + +describe("runtimeConfig", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + 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("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("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"); + }); +}); 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/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, }); From 3e2233b042ac3b7a5e5303b31845da7295a8ebd8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 07:53:10 +0000 Subject: [PATCH 02/13] Add build artifact smoke check for Vite prep Capture the webpack build outputs that deployments and Cypress already depend on so the later Vite migration has an executable contract to preserve. Co-authored-by: Chris Zetter --- package.json | 1 + scripts/check-build-artifacts.js | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 scripts/check-build-artifacts.js diff --git a/package.json b/package.json index 73aa2b06e..34571d35d 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", diff --git a/scripts/check-build-artifacts.js b/scripts/check-build-artifacts.js new file mode 100644 index 000000000..affea6e6b --- /dev/null +++ b/scripts/check-build-artifacts.js @@ -0,0 +1,45 @@ +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", + "scratch-gui/chunks", + "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}.`, +); From 8a0a14dd0ad6c58663496ebce445d144234582aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 07:53:58 +0000 Subject: [PATCH 03/13] Extract webpack artifact and header config for Vite prep Move copy patterns and cross-origin header rules into reusable config so Vite can preserve the same deployable artifacts and Pyodide/Scratch isolation behavior during the bundler switch. Co-authored-by: Chris Zetter --- config/buildArtifacts.js | 59 +++++++++++++++++++++++++ config/devServerSecurity.js | 37 ++++++++++++++++ webpack.config.js | 87 +++++++------------------------------ 3 files changed, 112 insertions(+), 71 deletions(-) create mode 100644 config/buildArtifacts.js create mode 100644 config/devServerSecurity.js 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/webpack.config.js b/webpack.config.js index 3c090b227..36ada2b26 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,6 +5,16 @@ 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"); dotenv.config({ path: path.resolve(__dirname, ".env") }); @@ -51,15 +61,8 @@ const cspApiMultipleOrigins = String(process.env.CSP_API_MULTIPLE_ORIGINS || "") .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", -); +const scratchStaticDir = getScratchStaticDir(__dirname); +const scratchChunkDir = getScratchChunkDir(__dirname); const moduleRules = [ { @@ -193,30 +196,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; }, }, @@ -239,16 +221,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", }; @@ -298,35 +271,7 @@ const scratchConfig = { }, }), 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", From 0118a09f664f3c5f595f8b76f532566491423470 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 07:54:41 +0000 Subject: [PATCH 04/13] Make inline asset imports explicit for Vite prep Mark SCSS and Markdown imports that are consumed as strings so Vite can preserve those semantics without relying on webpack loader defaults. Co-authored-by: Chris Zetter --- package.json | 2 ++ src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx | 2 +- .../Menus/Sidebar/InstructionsPanel/InstructionsPanel.jsx | 2 +- src/containers/WebComponentLoader.jsx | 6 +++--- src/scratch.jsx | 2 +- src/utils/setupTests.js | 2 +- 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 34571d35d..fe2168bab 100644 --- a/package.json +++ b/package.json @@ -199,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/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx index b31820749..4bc3bc3fa 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx @@ -6,7 +6,7 @@ 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, 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/containers/WebComponentLoader.jsx b/src/containers/WebComponentLoader.jsx index cffa58b7d..f6f654afb 100644 --- a/src/containers/WebComponentLoader.jsx +++ b/src/containers/WebComponentLoader.jsx @@ -27,9 +27,9 @@ 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 { diff --git a/src/scratch.jsx b/src/scratch.jsx index e82a093db..af7b9f14a 100644 --- a/src/scratch.jsx +++ b/src/scratch.jsx @@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"; 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, 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"; }); From 20262aea1367de06747f9d2a6ae275135c91846d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 07:55:33 +0000 Subject: [PATCH 05/13] Isolate Scratch template parameters for Vite prep Move Scratch CSP origin and template parameter generation into a tested helper so a future Vite HTML transform can reuse the same deployment security rules. Co-authored-by: Chris Zetter --- .../ScratchEditor/ScratchEditor.jsx | 4 +- src/utils/scratchTemplateConfig.cjs | 53 +++++++++++++++++ src/utils/scratchTemplateConfig.test.js | 55 ++++++++++++++++++ webpack.config.js | 58 ++++--------------- 4 files changed, 121 insertions(+), 49 deletions(-) create mode 100644 src/utils/scratchTemplateConfig.cjs create mode 100644 src/utils/scratchTemplateConfig.test.js diff --git a/src/components/ScratchEditor/ScratchEditor.jsx b/src/components/ScratchEditor/ScratchEditor.jsx index 0d260ac31..bf793e8dc 100644 --- a/src/components/ScratchEditor/ScratchEditor.jsx +++ b/src/components/ScratchEditor/ScratchEditor.jsx @@ -4,10 +4,10 @@ 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", { 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/webpack.config.js b/webpack.config.js index 36ada2b26..2e47fcb25 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -15,6 +15,9 @@ const { crossOriginHeaders, setCrossOriginResourcePolicy, } = require("./config/devServerSecurity"); +const { + getScratchTemplateParameters, +} = require("./src/utils/scratchTemplateConfig.cjs"); dotenv.config({ path: path.resolve(__dirname, ".env") }); @@ -22,47 +25,15 @@ 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), - ) - .filter(Boolean) - .join(" "); - 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 moduleRules = [ { @@ -261,14 +232,7 @@ const scratchConfig = { template: "src/scratch.html", filename: "scratch.html", chunks: ["scratch"], - templateParameters: { - publicUrl: publicUrl, - cspApiOrigin, - cspApiMultipleOrigins, - cspAssetOrigin, - cspScratchLibraryAssetOrigin, - isDev, - }, + templateParameters: scratchTemplateParameters, }), new CopyWebpackPlugin({ patterns: getScratchCopyPatterns({ scratchStaticDir, scratchChunkDir }), From 576164384729bf1b6c560c7e962576a24753f31c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 07:56:13 +0000 Subject: [PATCH 06/13] Isolate Pyodide worker URL generation for Vite prep Keep the stable PyodideWorker.js artifact contract in one tested helper so Vite output changes can be handled without touching the runner lifecycle. Co-authored-by: Chris Zetter --- .../PyodideRunner/PyodideRunner.jsx | 15 +------ src/utils/pyodideWorkerUrl.js | 23 ++++++++++ src/utils/pyodideWorkerUrl.test.js | 44 +++++++++++++++++++ 3 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 src/utils/pyodideWorkerUrl.js create mode 100644 src/utils/pyodideWorkerUrl.test.js diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index 3771dca97..fb727a95f 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -28,18 +28,7 @@ import OutputViewToggle from "../OutputViewToggle"; import { SettingsContext } from "../../../../../utils/settings"; import RunnerControls from "../../../../RunButton/RunnerControls"; import { publicPath } from "../../../../../utils/runtimeConfig"; - -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 { createPyodideWorkerUrl } from "../../../../../utils/pyodideWorkerUrl"; const PyodideRunner = ({ active, @@ -81,7 +70,7 @@ const PyodideRunner = ({ useEffect(() => { if (active) { - const workerUrl = getWorkerURL(publicPath("PyodideWorker.js")); + const workerUrl = createPyodideWorkerUrl(); const worker = new Worker(workerUrl); setPyodideWorker(worker); } diff --git a/src/utils/pyodideWorkerUrl.js b/src/utils/pyodideWorkerUrl.js new file mode 100644 index 000000000..d645101c6 --- /dev/null +++ b/src/utils/pyodideWorkerUrl.js @@ -0,0 +1,23 @@ +import { publicPath } from "./runtimeConfig"; + +export const PYODIDE_WORKER_ARTIFACT = "PyodideWorker.js"; + +export const getPyodideWorkerScriptUrl = () => + publicPath(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..80c1e19af --- /dev/null +++ b/src/utils/pyodideWorkerUrl.test.js @@ -0,0 +1,44 @@ +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("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); + }); +}); From a918c2458d101ea2c36949a0a2f57c2ecd4ac236 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 07:56:34 +0000 Subject: [PATCH 07/13] Document Jest scope for Vite prep Keep the initial Vite migration focused on the bundler by documenting why Jest should remain in place until build parity is proven. Co-authored-by: Chris Zetter --- docs/ViteMigration.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/ViteMigration.md 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. From ffada37534ecdb0d1bf8cfb6ffb2c85534f74516 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 07:57:11 +0000 Subject: [PATCH 08/13] Fix Skulpt runtime path formatting Keep the Vite-prep runtime path helper calls aligned with the repository ESLint/Prettier rules so validation stays focused on behavior. Co-authored-by: Chris Zetter --- .../Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx index 302842343..8eadc57cb 100644 --- a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx @@ -48,14 +48,10 @@ const externalLibraries = { dependencies: [assetPath("libraries/processing/p5/p5.js")], }, "./py5_imported/__init__.js": { - path: assetPath( - "shims/processing/py5_imported_mode/py5_imported.js", - ), + path: assetPath("shims/processing/py5_imported_mode/py5_imported.js"), }, "./py5_imported_mode.py": { - path: assetPath( - "shims/processing/py5_imported_mode/py5_imported_mode.py", - ), + path: assetPath("shims/processing/py5_imported_mode/py5_imported_mode.py"), }, "./p5/__init__.js": { path: assetPath("shims/processing/p5/p5-shim.js"), From 6651941efd0fce08e37649fd17ca7c6b329bfea6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 07:59:04 +0000 Subject: [PATCH 09/13] Align smoke check with Scratch build chunks Match the production webpack copy contract by checking Scratch chunks under build/chunks, while leaving the dev-server scratch-gui/chunks path to the server static config. Co-authored-by: Chris Zetter --- scripts/check-build-artifacts.js | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/check-build-artifacts.js b/scripts/check-build-artifacts.js index affea6e6b..3bde74291 100644 --- a/scripts/check-build-artifacts.js +++ b/scripts/check-build-artifacts.js @@ -21,7 +21,6 @@ const requiredFiles = [ "shims/sense_hat/sense_hat_blob.py", "python-error-copydecks/en/copydeck.json", "scratch-gui/static/assets", - "scratch-gui/chunks", "chunks", "vendor/react.production.min.js", "vendor/react-dom.production.min.js", From 4b4449d01379208ecc3178dba5aec64c7a6c2833 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 08:07:08 +0000 Subject: [PATCH 10/13] Fallback empty renderer URLs to browser origin Prevent blank HTML_RENDERER_URL or ASSETS_URL values from producing invalid postMessage origins during the Vite prep runtime-config abstraction. Co-authored-by: Chris Zetter --- src/utils/runtimeConfig.js | 8 ++++++-- src/utils/runtimeConfig.test.js | 9 +++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/utils/runtimeConfig.js b/src/utils/runtimeConfig.js index c2b9960d5..79d4dac89 100644 --- a/src/utils/runtimeConfig.js +++ b/src/utils/runtimeConfig.js @@ -17,10 +17,14 @@ export const isTest = () => getNodeEnv() === "test"; export const getPublicUrl = () => getRuntimeEnv("PUBLIC_URL", ""); -export const getAssetsUrl = () => getRuntimeEnv("ASSETS_URL", getPublicUrl()); +const getBrowserOrigin = () => + typeof window === "undefined" ? "" : window.location.origin; + +export const getAssetsUrl = () => + getRuntimeEnv("ASSETS_URL") || getPublicUrl() || getBrowserOrigin(); export const getHtmlRendererUrl = () => - getRuntimeEnv("HTML_RENDERER_URL", getPublicUrl()); + getRuntimeEnv("HTML_RENDERER_URL") || getPublicUrl() || getBrowserOrigin(); export const runtimeUrl = (baseUrl, path) => { const normalizedPath = trimLeadingSlash(path); diff --git a/src/utils/runtimeConfig.test.js b/src/utils/runtimeConfig.test.js index 23f2e1e23..3cbfc4425 100644 --- a/src/utils/runtimeConfig.test.js +++ b/src/utils/runtimeConfig.test.js @@ -35,6 +35,15 @@ describe("runtimeConfig", () => { 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("joins paths without duplicating slashes", () => { process.env.PUBLIC_URL = "https://static.example.com/branch/"; process.env.ASSETS_URL = "https://assets.example.com/branch"; From fec9413a1f14e596a316c1402d0c749136a904f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 08:15:38 +0000 Subject: [PATCH 11/13] Use absolute public URL for Pyodide blob worker Blob workers cannot importScripts root-relative paths, so use the browser origin when PUBLIC_URL is empty while preserving the stable PyodideWorker.js artifact contract. Co-authored-by: Chris Zetter --- src/utils/pyodideWorkerUrl.js | 4 ++-- src/utils/pyodideWorkerUrl.test.js | 8 ++++++++ src/utils/runtimeConfig.js | 5 +++++ src/utils/runtimeConfig.test.js | 11 +++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/utils/pyodideWorkerUrl.js b/src/utils/pyodideWorkerUrl.js index d645101c6..6c16c1ee4 100644 --- a/src/utils/pyodideWorkerUrl.js +++ b/src/utils/pyodideWorkerUrl.js @@ -1,9 +1,9 @@ -import { publicPath } from "./runtimeConfig"; +import { publicOriginPath } from "./runtimeConfig"; export const PYODIDE_WORKER_ARTIFACT = "PyodideWorker.js"; export const getPyodideWorkerScriptUrl = () => - publicPath(PYODIDE_WORKER_ARTIFACT); + publicOriginPath(PYODIDE_WORKER_ARTIFACT); export const getPyodideWorkerBootstrap = (workerScriptUrl) => ` /* global PyodideWorker */ diff --git a/src/utils/pyodideWorkerUrl.test.js b/src/utils/pyodideWorkerUrl.test.js index 80c1e19af..e5740ce98 100644 --- a/src/utils/pyodideWorkerUrl.test.js +++ b/src/utils/pyodideWorkerUrl.test.js @@ -21,6 +21,14 @@ describe("pyodideWorkerUrl", () => { 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");', diff --git a/src/utils/runtimeConfig.js b/src/utils/runtimeConfig.js index 79d4dac89..c13ce2dd0 100644 --- a/src/utils/runtimeConfig.js +++ b/src/utils/runtimeConfig.js @@ -20,6 +20,8 @@ export const getPublicUrl = () => getRuntimeEnv("PUBLIC_URL", ""); const getBrowserOrigin = () => typeof window === "undefined" ? "" : window.location.origin; +export const getPublicOriginUrl = () => getPublicUrl() || getBrowserOrigin(); + export const getAssetsUrl = () => getRuntimeEnv("ASSETS_URL") || getPublicUrl() || getBrowserOrigin(); @@ -35,6 +37,9 @@ export const runtimeUrl = (baseUrl, path) => { 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) => diff --git a/src/utils/runtimeConfig.test.js b/src/utils/runtimeConfig.test.js index 3cbfc4425..449d0337f 100644 --- a/src/utils/runtimeConfig.test.js +++ b/src/utils/runtimeConfig.test.js @@ -2,8 +2,10 @@ import { assetPath, getAssetsUrl, getHtmlRendererUrl, + getPublicOriginUrl, getRuntimeEnv, htmlRendererPath, + publicOriginPath, publicPath, } from "./runtimeConfig"; @@ -65,4 +67,13 @@ describe("runtimeConfig", () => { 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`, + ); + }); }); From b21bec23f93303078b52c71b46c1680c28a0afcd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 08:22:05 +0000 Subject: [PATCH 12/13] Allow same-origin HTML renderer messages Preserve HTML preview behavior when allowed iframe origins are not configured by accepting messages from the same origin as the renderer iframe. Co-authored-by: Chris Zetter --- src/utils/iframeUtils.js | 5 ++++- src/utils/iframeUtils.test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/utils/iframeUtils.test.js diff --git a/src/utils/iframeUtils.js b/src/utils/iframeUtils.js index cdca7f2cc..42b31199a 100644 --- a/src/utils/iframeUtils.js +++ b/src/utils/iframeUtils.js @@ -1,10 +1,13 @@ import { getRuntimeEnv, isTest } from "./runtimeConfig"; +const isSameOrigin = (origin) => + typeof window !== "undefined" && origin === window.location.origin; + export function allowedIframeHost(origin) { const allowedHosts = getRuntimeEnv("REACT_APP_ALLOWED_IFRAME_ORIGINS") .split(",") .filter(Boolean); - return isTest() || allowedHosts.includes(origin); + 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); + }); +}); From a8a01ede97187a92ff03740b0e336a292fc94812 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 11:01:19 +0000 Subject: [PATCH 13/13] Inject runtime config for webpack bundles Provide a bundled runtime env object so the prep branch's central config helper works with webpack's static env replacement and blob workers can resolve absolute asset URLs in Cypress. Co-authored-by: Chris Zetter --- src/utils/runtimeConfig.js | 20 +++++++++++++++----- src/utils/runtimeConfig.test.js | 18 ++++++++++++++++++ webpack.config.js | 19 +++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/utils/runtimeConfig.js b/src/utils/runtimeConfig.js index c13ce2dd0..6d969bffb 100644 --- a/src/utils/runtimeConfig.js +++ b/src/utils/runtimeConfig.js @@ -1,6 +1,14 @@ +/* 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]; @@ -17,16 +25,18 @@ export const isTest = () => getNodeEnv() === "test"; export const getPublicUrl = () => getRuntimeEnv("PUBLIC_URL", ""); -const getBrowserOrigin = () => - typeof window === "undefined" ? "" : window.location.origin; +export const getRuntimeOrigin = () => { + if (typeof globalThis === "undefined") return ""; + return globalThis.location?.origin || ""; +}; -export const getPublicOriginUrl = () => getPublicUrl() || getBrowserOrigin(); +export const getPublicOriginUrl = () => getPublicUrl() || getRuntimeOrigin(); export const getAssetsUrl = () => - getRuntimeEnv("ASSETS_URL") || getPublicUrl() || getBrowserOrigin(); + getRuntimeEnv("ASSETS_URL") || getPublicUrl() || getRuntimeOrigin(); export const getHtmlRendererUrl = () => - getRuntimeEnv("HTML_RENDERER_URL") || getPublicUrl() || getBrowserOrigin(); + getRuntimeEnv("HTML_RENDERER_URL") || getPublicUrl() || getRuntimeOrigin(); export const runtimeUrl = (baseUrl, path) => { const normalizedPath = trimLeadingSlash(path); diff --git a/src/utils/runtimeConfig.test.js b/src/utils/runtimeConfig.test.js index 449d0337f..4a2778f63 100644 --- a/src/utils/runtimeConfig.test.js +++ b/src/utils/runtimeConfig.test.js @@ -3,6 +3,7 @@ import { getAssetsUrl, getHtmlRendererUrl, getPublicOriginUrl, + getRuntimeOrigin, getRuntimeEnv, htmlRendererPath, publicOriginPath, @@ -11,6 +12,7 @@ import { describe("runtimeConfig", () => { const originalEnv = process.env; + const originalBundledEnv = global.__RUNTIME_ENV__; beforeEach(() => { process.env = { ...originalEnv }; @@ -18,6 +20,7 @@ describe("runtimeConfig", () => { afterEach(() => { process.env = originalEnv; + global.__RUNTIME_ENV__ = originalBundledEnv; }); it("reads environment values dynamically", () => { @@ -28,6 +31,17 @@ describe("runtimeConfig", () => { ); }); + 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; @@ -46,6 +60,10 @@ describe("runtimeConfig", () => { 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"; diff --git a/webpack.config.js b/webpack.config.js index 2e47fcb25..2428cdf39 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,4 +1,5 @@ const path = require("path"); +const webpack = require("webpack"); const dotenv = require("dotenv"); const Dotenv = require("dotenv-webpack"); const HtmlWebpackPlugin = require("html-webpack-plugin"); @@ -34,6 +35,22 @@ const scratchTemplateParameters = getScratchTemplateParameters({ 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), + ) + .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 = [ { @@ -180,6 +197,7 @@ const mainConfig = { path: "./.env", systemvars: true, }), + runtimeEnvPlugin, new HtmlWebpackPlugin({ inject: "body", template: "src/web-component.html", @@ -227,6 +245,7 @@ const scratchConfig = { path: "./.env", systemvars: true, }), + runtimeEnvPlugin, new HtmlWebpackPlugin({ inject: "body", template: "src/scratch.html",