diff --git a/README.md b/README.md index 5d20b0ed8..4ff1fe8f5 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ The `editor-wc` tag accepts the following attributes, which must be provided as - `output_split_view`: Start with split view in output panel (defaults to `false`, i.e. tabbed view) - `project_name_editable`: Allow the user to edit the project name in the project bar (defaults to `false`) - `react_app_api_endpoint`: API endpoint to send project-related requests to +- `offline_enabled`: Show an offline indicator when the user's device loses connectivity (defaults to `false`). Requires the service worker to be registered on the host page — see [Offline support](#offline-support). - `read_only`: Display the editor in read only mode (defaults to `false`) - `sense_hat_always_enabled`: Show the Astro Pi Sense HAT emulator on page load (defaults to `false`) - `show_save_prompt`: Prompt the user to save their work (defaults to `false`) @@ -130,6 +131,33 @@ The host page is able to communicate with the web component via custom methods p This allows the host page to query the current code in the editor and to control code runs from outside the web component, for example. +### Offline support + +The web component ships a service worker (`service-worker.js`) that caches the editor shell and Pyodide assets so the component remains usable after a network loss. + +To enable offline support on your host page: + +1. Register the service worker from your host page (or let the bundled `web-component.html` do it automatically): + ```js + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("./service-worker.js"); + } + ``` +2. Pass the `offline_enabled` attribute to the web component so the offline indicator is shown when connectivity is lost: + ```html + + ``` + +Offline mode is opt-in — neither the service worker registration nor the offline badge will appear unless these steps are taken. + +#### Developing with offline support + +The service worker is not registered in development by default. To enable it locally, set the environment variable before starting the dev server: + +```sh +REACT_APP_ENABLE_SERVICE_WORKER=true yarn start +``` + ## Development ### Previewing diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 000000000..23a248544 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,178 @@ +/* eslint-env serviceworker */ +/* eslint-disable no-restricted-globals */ + +// "editor-app-v1" and "editor-translations-v1" are replaced with the package version at build time (see webpack.config.js) +const APP_CACHE = "editor-app-v1"; +const TRANSLATIONS_CACHE = "editor-translations-v1"; +const PYODIDE_CACHE = "pyodide-v0.26.2"; + +// Minimal set of assets to pre-cache on install +// All other assets (chunks, translations, etc.) are cached dynamically on first use via the network-first fetch handler below +const APP_SHELL = [ + "./web-component.html", + "./web-component.js", + "./PyodideWorker.js", + "./manifest.json", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + Promise.all([ + caches + .open(APP_CACHE) + .then((cache) => + cache + .addAll(APP_SHELL) + .catch((err) => + console.warn( + "[SW] Pre-cache failed, will rely on dynamic caching:", + err, + ), + ), + ), + caches + .open(TRANSLATIONS_CACHE) + .then((cache) => + cache + .addAll(["./translations/en.json"]) + .catch((err) => + console.warn( + "[SW] Translation pre-cache failed, will rely on dynamic caching:", + err, + ), + ), + ), + ]), + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((key) => key !== APP_CACHE && key !== TRANSLATIONS_CACHE && key !== PYODIDE_CACHE) + .map((key) => { + console.log("[SW] Deleting old cache:", key); + return caches.delete(key); + }), + ), + ), + ); + self.clients.claim(); +}); + +// Pyodide needs SharedArrayBuffer which requires the page to be cross-origin isolated. That means serving COOP + COEP on the HTML response, and CORP on every cross-origin resource the page loads +function addSecurityHeaders(response) { + if (response.type === "opaque") return response; + const headers = new Headers(response.headers); + headers.set("Cross-Origin-Opener-Policy", "same-origin"); + headers.set("Cross-Origin-Embedder-Policy", "require-corp"); + headers.set("Cross-Origin-Resource-Policy", "cross-origin"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +// Tracks whether any network-first request has fallen back to cache, so that we can broadcast ONLINE when the network becomes reachable again +let servingFromCache = false; + +self.addEventListener("online", () => { + servingFromCache = false; + broadcast("ONLINE"); +}); + +// Send CHECK_ONLINE when offline. We probe the network directly here because SW-initiated fetches bypass the SW's own fetch handler, hitting the network without being served from cache +self.addEventListener("message", (event) => { + if (event.data?.type !== "CHECK_ONLINE") return; + fetch("./manifest.json", { cache: "no-store" }) + .then(() => { + servingFromCache = false; + broadcast("ONLINE"); + }) + .catch(() => {}); +}); + +function broadcast(type) { + self.clients + .matchAll() + .then((clients) => clients.forEach((c) => c.postMessage({ type }))); +} + +// Network-first try the network, update the cache, fall back to cache +async function networkFirst(request, cacheName) { + const cache = await caches.open(cacheName); + try { + const networkResponse = await fetch(request); + if (networkResponse.ok) { + cache.put(request, addSecurityHeaders(networkResponse.clone())); + if (servingFromCache) { + servingFromCache = false; + broadcast("ONLINE"); + } + } + return addSecurityHeaders(networkResponse); + } catch { + const cached = await cache.match(request); + if (cached) { + servingFromCache = true; + broadcast("OFFLINE"); + return addSecurityHeaders(cached); + } + return Response.error(); + } +} + +// Cache-first: serve from cache when available, populate cache on first fetch +// importScripts produces opaque responses we can't modify, so we re-fetch as cors +async function cacheFirst(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request.url); + if (cached) return cached; + + const corsRequest = new Request(request.url, { + mode: "cors", + credentials: "omit", + }); + const networkResponse = await fetch(corsRequest); + cache.put(request.url, addSecurityHeaders(networkResponse.clone())); + return addSecurityHeaders(networkResponse); +} + +self.addEventListener("fetch", (event) => { + // Chrome bug: skip only-if-cached requests for cross-origin resources + if ( + event.request.cache === "only-if-cached" && + event.request.mode !== "same-origin" + ) { + return; + } + + const url = new URL(event.request.url); + + // Pyodide CDN assets are cache-first since URLs are version-pinned + if ( + url.hostname === "cdn.jsdelivr.net" && + url.pathname.includes("/pyodide/") + ) { + event.respondWith(cacheFirst(event.request, PYODIDE_CACHE)); + return; + } + + // Translation files get their own cache so they can be evicted independently of the app shell + if ( + url.origin === self.location.origin && + url.pathname.includes("/translations/") + ) { + event.respondWith(networkFirst(event.request, TRANSLATIONS_CACHE)); + return; + } + + // Same-origin app assets are network-first so users get fresh content online + if (url.origin === self.location.origin) { + event.respondWith(networkFirst(event.request, APP_CACHE)); + } +}); diff --git a/public/translations/en.json b/public/translations/en.json index 584618c9f..7b2b2c7cf 100644 --- a/public/translations/en.json +++ b/public/translations/en.json @@ -83,6 +83,9 @@ "renameSave": "Save project name", "save": "Save", "loginToSave": "Log in to save", + "offline": "Offline", + "offlineTooltipDevice": "Code changes are being saved to your device.", + "offlineTooltipContinue": "You can keep coding and your work will be saved when you are back online.", "settings": "Settings" }, "imagePanel": { diff --git a/src/assets/icons/offline.svg b/src/assets/icons/offline.svg new file mode 100644 index 000000000..0e4cb349a --- /dev/null +++ b/src/assets/icons/offline.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/stylesheets/InternalStyles.scss b/src/assets/stylesheets/InternalStyles.scss index 01f136b70..f790feb70 100644 --- a/src/assets/stylesheets/InternalStyles.scss +++ b/src/assets/stylesheets/InternalStyles.scss @@ -37,6 +37,7 @@ @use "./Button" as *; @use "./DesignSystemButton" as *; @use "./SaveStatus" as *; +@use "./OfflineIndicator" as *; @use "./ContextMenu" as *; @use "./FilePanel" as *; // needs to be below Button @use "./EmbeddedViewer" as *; diff --git a/src/assets/stylesheets/OfflineIndicator.scss b/src/assets/stylesheets/OfflineIndicator.scss new file mode 100644 index 000000000..97aa1168b --- /dev/null +++ b/src/assets/stylesheets/OfflineIndicator.scss @@ -0,0 +1,67 @@ +@use "./rpf_design_system/colours" as *; +@use "./rpf_design_system/spacing" as *; + +.offline-badge { + position: relative; + display: inline-flex; + align-items: center; + gap: $space-0-5; + padding-inline: $space-0-5; + padding-block: $space-0-5; + border-radius: 100vw; + border: 3px solid $rpf-red-400; + color: $rpf-red-900; + white-space: nowrap; + cursor: default; + background-color: $rpf-red-100; + font-weight: bold; + + svg { + flex-shrink: 0; + } + + &__tooltip { + position: absolute; + inset-block-start: calc(100% + $space-1); + inset-inline-end: 0; + inline-size: 20rem; + padding: $space-1; + border-radius: $space-0-5; + background: #1d1d1d; + color: #fff; + font-size: 1rem; + white-space: normal; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.15s ease; + z-index: 100; + + &::before { + content: ""; + position: absolute; + inset-block-start: -8px; + inset-inline-end: 20px; + inline-size: 0; + block-size: 0; + border-inline-start: 8px solid transparent; + border-inline-end: 8px solid transparent; + border-block-end: 8px solid #1d1d1d; + } + + p { + margin: 0; + + & + p { + margin-block-start: $space-1-5; + } + } + } + + &:hover &__tooltip, + &:focus-within &__tooltip { + opacity: 1; + visibility: visible; + pointer-events: auto; + } +} diff --git a/src/components/SaveButton/SaveButton.jsx b/src/components/SaveButton/SaveButton.jsx index 3b47ecd5c..2ee1142ad 100644 --- a/src/components/SaveButton/SaveButton.jsx +++ b/src/components/SaveButton/SaveButton.jsx @@ -8,7 +8,9 @@ import { isOwner } from "../../utils/projectHelpers"; import DesignSystemButton from "../DesignSystemButton/DesignSystemButton"; import SaveIcon from "../../assets/icons/save.svg"; +import OfflineIcon from "../../assets/icons/offline.svg"; import { triggerSave } from "../../redux/EditorSlice"; +import useIsOnline from "../../hooks/useIsOnline"; const SaveButton = ({ className, type, fill = false }) => { const dispatch = useDispatch(); @@ -19,6 +21,8 @@ const SaveButton = ({ className, type, fill = false }) => { const webComponent = useSelector((state) => state.editor.webComponent); const user = useSelector((state) => state.auth.user); const project = useSelector((state) => state.editor.project); + const offlineEnabled = useSelector((state) => state.editor.offlineEnabled); + const isOnline = useIsOnline(); useEffect(() => { if (!type) { @@ -36,24 +40,35 @@ const SaveButton = ({ className, type, fill = false }) => { const projectOwner = isOwner(user, project); + if (loading !== "success" || projectOwner || !buttonType) return null; + + if (offlineEnabled && !isOnline && !user) { + return ( +
+ + {t("header.offline")} +
+

{t("header.offlineTooltipDevice")}

+

{t("header.offlineTooltipContinue")}

+
+
+ ); + } + return ( - loading === "success" && - !projectOwner && - buttonType && ( - } - type={buttonType} - fill={fill} - /> - ) + } + type={buttonType} + fill={fill} + /> ); }; diff --git a/src/components/SaveButton/SaveButton.test.js b/src/components/SaveButton/SaveButton.test.js index 146720e0d..d917b5585 100644 --- a/src/components/SaveButton/SaveButton.test.js +++ b/src/components/SaveButton/SaveButton.test.js @@ -4,6 +4,9 @@ import { Provider } from "react-redux"; import configureStore from "redux-mock-store"; import { triggerSave } from "../../redux/EditorSlice"; import SaveButton from "./SaveButton"; +import useIsOnline from "../../hooks/useIsOnline"; + +jest.mock("../../hooks/useIsOnline"); const logInHandler = jest.fn(); @@ -12,6 +15,10 @@ describe("When project is loaded", () => { document.addEventListener("editor-logIn", logInHandler); }); + beforeEach(() => { + useIsOnline.mockReturnValue(true); + }); + describe("With logged in user", () => { let store; @@ -204,6 +211,69 @@ describe("When project is loaded", () => { }); }); + describe("offline badge", () => { + const offlineState = { + editor: { + loading: "success", + webComponent: true, + offlineEnabled: true, + project: {}, + }, + auth: {}, + }; + + beforeEach(() => { + useIsOnline.mockReturnValue(false); + }); + + test("shows offline badge when offline and offlineEnabled is true", () => { + const store = configureStore([])(offlineState); + render( + + + , + ); + expect(screen.queryByText("header.offline")).toBeInTheDocument(); + }); + + test("does not show offline badge when offlineEnabled is false", () => { + const store = configureStore([])({ + ...offlineState, + editor: { ...offlineState.editor, offlineEnabled: false }, + }); + render( + + + , + ); + expect(screen.queryByText("header.offline")).not.toBeInTheDocument(); + }); + + test("does not show offline badge when online, even if offlineEnabled is true", () => { + useIsOnline.mockReturnValue(true); + const store = configureStore([])(offlineState); + render( + + + , + ); + expect(screen.queryByText("header.offline")).not.toBeInTheDocument(); + }); + + test("does not show offline badge when user is logged in", () => { + const store = configureStore([])({ + ...offlineState, + auth: { user: { profile: { user: "some-user" } } }, + }); + render( + + + , + ); + expect(screen.queryByText("header.offline")).not.toBeInTheDocument(); + }); + }); + afterAll(() => { document.removeEventListener("editor-logIn", logInHandler); }); diff --git a/src/containers/WebComponentLoader.jsx b/src/containers/WebComponentLoader.jsx index df4aa7cc4..7f86dca3f 100644 --- a/src/containers/WebComponentLoader.jsx +++ b/src/containers/WebComponentLoader.jsx @@ -3,6 +3,7 @@ import { useSelector, useDispatch } from "react-redux"; import { disableTheming, setSenseHatAlwaysEnabled, + setOfflineEnabled, setLoadRemixDisabled, setReactAppApiEndpoint, setScratchApiEndpoint, @@ -75,6 +76,7 @@ const WebComponentLoader = (props) => { withSidebar = false, loadCache = true, // Always use cache unless explicitly disabled initialProject = null, + offlineEnabled = false, } = props; const dispatch = useDispatch(); @@ -199,6 +201,10 @@ const WebComponentLoader = (props) => { dispatch(setReadOnly(readOnly)); }, [readOnly, dispatch]); + useEffect(() => { + dispatch(setOfflineEnabled(offlineEnabled)); + }, [offlineEnabled, dispatch]); + useEffect(() => { // Create a script element to save the existing Prism object if there is one const script = document.createElement("script"); diff --git a/src/hooks/useIsOnline.js b/src/hooks/useIsOnline.js new file mode 100644 index 000000000..7b03bb9a4 --- /dev/null +++ b/src/hooks/useIsOnline.js @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +const useIsOnline = () => { + const [isOnline, setIsOnline] = useState(navigator.onLine); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + // The service worker broadcasts OFFLINE when a network-first fetch falls back to cache (catching cases where navigator.onLine hasn't settled on page reload), and ONLINE when the network becomes reachable again after a cache fallback period + const handleSWMessage = ({ data }) => { + if (data?.type === "OFFLINE") setIsOnline(false); + if (data?.type === "ONLINE") setIsOnline(true); + }; + if ("serviceWorker" in navigator) { + navigator.serviceWorker.addEventListener("message", handleSWMessage); + } + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + if ("serviceWorker" in navigator) { + navigator.serviceWorker.removeEventListener("message", handleSWMessage); + } + }; + }, []); + + // While offline, poll the SW every 3s to check if the network has returned, broadcast ONLINE if successful + useEffect(() => { + if (isOnline || !("serviceWorker" in navigator)) return; + const interval = setInterval(async () => { + const reg = await navigator.serviceWorker.ready; + reg.active?.postMessage({ type: "CHECK_ONLINE" }); + }, 3000); + return () => clearInterval(interval); + }, [isOnline]); + + return isOnline; +}; + +export default useIsOnline; diff --git a/src/hooks/useIsOnline.test.js b/src/hooks/useIsOnline.test.js new file mode 100644 index 000000000..5418a77a3 --- /dev/null +++ b/src/hooks/useIsOnline.test.js @@ -0,0 +1,144 @@ +import { act, renderHook } from "@testing-library/react"; +import useIsOnline from "./useIsOnline"; + +const dispatchWindowEvent = (type) => { + act(() => { + window.dispatchEvent(new Event(type)); + }); +}; + +describe("useIsOnline", () => { + let swEventTarget; + let mockPostMessage; + + beforeEach(() => { + Object.defineProperty(navigator, "onLine", { + configurable: true, + get: () => true, + }); + + mockPostMessage = jest.fn(); + swEventTarget = new EventTarget(); + swEventTarget.ready = Promise.resolve({ + active: { postMessage: mockPostMessage }, + }); + + // jsdom doesn't implement navigator.serviceWorker so provide a minimal stub + Object.defineProperty(navigator, "serviceWorker", { + configurable: true, + value: swEventTarget, + }); + }); + + const dispatchSWMessage = (data) => { + act(() => { + swEventTarget.dispatchEvent(new MessageEvent("message", { data })); + }); + }; + + test("returns true when navigator.onLine is true", () => { + const { result } = renderHook(() => useIsOnline()); + expect(result.current).toBe(true); + }); + + test("returns false when navigator.onLine is false", () => { + Object.defineProperty(navigator, "onLine", { + configurable: true, + get: () => false, + }); + const { result } = renderHook(() => useIsOnline()); + expect(result.current).toBe(false); + }); + + test("updates to false when the offline window event fires", () => { + const { result } = renderHook(() => useIsOnline()); + dispatchWindowEvent("offline"); + expect(result.current).toBe(false); + }); + + test("updates to true when the online window event fires", () => { + const { result } = renderHook(() => useIsOnline()); + dispatchWindowEvent("offline"); + dispatchWindowEvent("online"); + expect(result.current).toBe(true); + }); + + test("updates to false when the service worker broadcasts OFFLINE", () => { + const { result } = renderHook(() => useIsOnline()); + dispatchSWMessage({ type: "OFFLINE" }); + expect(result.current).toBe(false); + }); + + test("updates to true when the service worker broadcasts ONLINE", () => { + const { result } = renderHook(() => useIsOnline()); + dispatchSWMessage({ type: "OFFLINE" }); + dispatchSWMessage({ type: "ONLINE" }); + expect(result.current).toBe(true); + }); + + test("ignores service worker messages with a different type", () => { + const { result } = renderHook(() => useIsOnline()); + dispatchSWMessage({ type: "OTHER" }); + expect(result.current).toBe(true); + }); + + describe("connectivity polling", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("does not poll while online", async () => { + renderHook(() => useIsOnline()); + await act(async () => { + jest.advanceTimersByTime(6000); + }); + expect(mockPostMessage).not.toHaveBeenCalled(); + }); + + test("sends CHECK_ONLINE to the service worker every 3 seconds while offline", async () => { + const { result } = renderHook(() => useIsOnline()); + dispatchSWMessage({ type: "OFFLINE" }); + expect(result.current).toBe(false); + + await act(async () => { + jest.advanceTimersByTime(3000); + }); + expect(mockPostMessage).toHaveBeenCalledTimes(1); + expect(mockPostMessage).toHaveBeenCalledWith({ type: "CHECK_ONLINE" }); + + await act(async () => { + jest.advanceTimersByTime(3000); + }); + expect(mockPostMessage).toHaveBeenCalledTimes(2); + }); + + test("stops polling when the service worker broadcasts ONLINE", async () => { + const { result } = renderHook(() => useIsOnline()); + dispatchSWMessage({ type: "OFFLINE" }); + dispatchSWMessage({ type: "ONLINE" }); + expect(result.current).toBe(true); + + await act(async () => { + jest.advanceTimersByTime(6000); + }); + expect(mockPostMessage).not.toHaveBeenCalled(); + }); + + test("stops polling on unmount", async () => { + const { result, unmount } = renderHook(() => useIsOnline()); + dispatchSWMessage({ type: "OFFLINE" }); + expect(result.current).toBe(false); + + unmount(); + + await act(async () => { + jest.advanceTimersByTime(6000); + }); + expect(mockPostMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js index 096189265..0d667ed4c 100644 --- a/src/redux/EditorSlice.js +++ b/src/redux/EditorSlice.js @@ -112,6 +112,7 @@ export const editorInitialState = { lastSaveAutosave: false, lastSavedTime: null, senseHatAlwaysEnabled: false, + offlineEnabled: false, senseHatEnabled: false, loadRemixDisabled: false, betaModalShowing: false, @@ -248,6 +249,9 @@ export const EditorSlice = createSlice({ setSenseHatAlwaysEnabled: (state, action) => { state.senseHatAlwaysEnabled = action.payload; }, + setOfflineEnabled: (state, action) => { + state.offlineEnabled = action.payload; + }, setSenseHatEnabled: (state, action) => { state.senseHatEnabled = action.payload; }, @@ -477,6 +481,7 @@ export const { setReadOnly, setInstructionsEditable, setSenseHatAlwaysEnabled, + setOfflineEnabled, setSenseHatEnabled, setLoadRemixDisabled, setReactAppApiEndpoint, diff --git a/src/redux/EditorSlice.test.js b/src/redux/EditorSlice.test.js index 5090ce66a..9b9a82146 100644 --- a/src/redux/EditorSlice.test.js +++ b/src/redux/EditorSlice.test.js @@ -11,6 +11,7 @@ import reducer, { setIsOutputOnly, setErrorDetails, setReadOnly, + setOfflineEnabled, addProjectComponent, updateProjectComponent, setCascadeUpdate, @@ -109,6 +110,14 @@ test("Action setReadOnly correctly sets readOnly", () => { expect(reducer(previousState, setReadOnly(true))).toEqual(expectedState); }); +test("Action setOfflineEnabled correctly sets offlineEnabled", () => { + const previousState = { offlineEnabled: false }; + const expectedState = { offlineEnabled: true }; + expect(reducer(previousState, setOfflineEnabled(true))).toEqual( + expectedState, + ); +}); + test("Action addProjectComponent adds component to project with correct content", () => { const previousState = { project: { diff --git a/src/web-component.html b/src/web-component.html index 0611083eb..c4bef9185 100644 --- a/src/web-component.html +++ b/src/web-component.html @@ -52,6 +52,18 @@ Editor Web component + <% if (htmlWebpackPlugin.options.enableSW) { %> + + <% } %>
@@ -97,6 +109,8 @@ newWebComp.setAttribute("with_projectbar", "true"); newWebComp.setAttribute("with_sidebar", "true"); newWebComp.setAttribute("use_editor_styles", "true"); + // see "Offline support" section in README for more details + newWebComp.setAttribute("offline_enabled", "true"); newWebComp.setAttribute( "sidebar_options", JSON.stringify([ diff --git a/src/web-component.js b/src/web-component.js index dcabd62c6..89d29da97 100644 --- a/src/web-component.js +++ b/src/web-component.js @@ -79,6 +79,7 @@ class WebComponent extends HTMLElement { "with_projectbar", "with_sidebar", "load_cache", + "offline_enabled", ]; } @@ -97,6 +98,7 @@ class WebComponent extends HTMLElement { "with_projectbar", "with_sidebar", "load_cache", + "offline_enabled", ]; const jsonAttrs = [ "instructions", diff --git a/webpack.config.js b/webpack.config.js index 2a95ff47b..9bf167c4f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -13,6 +13,8 @@ if (!publicUrl.endsWith("/")) { publicUrl += "/"; } const isDev = process.env.NODE_ENV !== "production"; +const enableSW = + !isDev || process.env.REACT_APP_ENABLE_SERVICE_WORKER === "true"; const toOrigin = (envVarName, value) => { const normalizedValue = String(value || "") @@ -223,6 +225,7 @@ const mainConfig = { template: "src/web-component.html", filename: "web-component.html", chunks: ["web-component"], + enableSW, }), new HtmlWebpackPlugin({ inject: "body", @@ -232,7 +235,18 @@ const mainConfig = { }), new CopyWebpackPlugin({ patterns: [ - { from: "public", to: "" }, + { + from: "public", + to: "", + transform: (content, filePath) => { + if (!filePath.endsWith("service-worker.js")) return content; + const version = process.env.npm_package_version; + return content + .toString() + .replace("editor-app-v1", `editor-app-v${version}`) + .replace("editor-translations-v1", `editor-translations-v${version}`); + }, + }, { from: "src/projects", to: "projects" }, ], }),