Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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
<editor-wc offline_enabled="true"></editor-wc>
```

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
Expand Down
178 changes: 178 additions & 0 deletions public/service-worker.js
Original file line number Diff line number Diff line change
@@ -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));
}
});
3 changes: 3 additions & 0 deletions public/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions src/assets/icons/offline.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/stylesheets/InternalStyles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 *;
Expand Down
67 changes: 67 additions & 0 deletions src/assets/stylesheets/OfflineIndicator.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
49 changes: 32 additions & 17 deletions src/components/SaveButton/SaveButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) {
Expand All @@ -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 (
<div className={classNames(className, "offline-badge")}>
<OfflineIcon />
<span>{t("header.offline")}</span>
<div className="offline-badge__tooltip">
<p>{t("header.offlineTooltipDevice")}</p>
<p>{t("header.offlineTooltipContinue")}</p>
</div>
</div>
);
}

return (
loading === "success" &&
!projectOwner &&
buttonType && (
<DesignSystemButton
className={classNames(className, {
"btn--primary": buttonType === "primary",
"btn--secondary": buttonType === "secondary",
"btn--tertiary": buttonType === "tertiary",
})}
onClick={onClickSave}
text={t(user ? "header.save" : "header.loginToSave")}
textAlways
icon={<SaveIcon />}
type={buttonType}
fill={fill}
/>
)
<DesignSystemButton
className={classNames(className, {
"btn--primary": buttonType === "primary",
"btn--secondary": buttonType === "secondary",
"btn--tertiary": buttonType === "tertiary",
})}
onClick={onClickSave}
text={t(user ? "header.save" : "header.loginToSave")}
textAlways
icon={<SaveIcon />}
type={buttonType}
fill={fill}
/>
);
};

Expand Down
Loading
Loading