Skip to content

Commit

Permalink
Add service worker for offline compatibility (#115)
Browse files Browse the repository at this point in the history
For the moment this is opt in via flag=sw on the URL so existing embedders won't be affected.

The main motivation here is to experiment with PWA support in micro:bit Python Editor which is being worked on separately.
  • Loading branch information
microbit-robert authored May 28, 2024
1 parent 6204a0b commit 9d81538
Show file tree
Hide file tree
Showing 8 changed files with 159 additions and 5 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ jobs:
steps:
# Note: This workflow will not run on forks without modification; we're open to making steps
# that rely on our deployment infrastructure conditional. Please open an issue.
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Configure node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: "npm"
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ build:

dist: build
mkdir -p $(BUILD)/build
cp -r $(SRC)/*.html $(SRC)/term.js src/examples $(BUILD)
cp -r $(SRC)/*.html $(SRC)/term.js src/examples $(SRC)/build/sw.js $(BUILD)
cp $(SRC)/build/firmware.js $(SRC)/build/simulator.js $(SRC)/build/firmware.wasm $(BUILD)/build/

watch: dist
Expand Down
3 changes: 2 additions & 1 deletion src/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ $(BUILD)/micropython.js: $(OBJ) jshal.js simulator-js
$(Q)emcc $(LDFLAGS) -o $(BUILD)/firmware.js $(OBJ) $(JSFLAGS)

simulator-js:
npx esbuild ./simulator.ts --bundle --outfile=$(BUILD)/simulator.js --loader:.svg=text
npx esbuild '--define:process.env.STAGE="$(STAGE)"' ./simulator.ts --bundle --outfile=$(BUILD)/simulator.js --loader:.svg=text
npx esbuild --define:process.env.VERSION="$$(node -e 'process.stdout.write(`"` + require("../package.json").version + `"`)')" ./sw.ts --bundle --outfile=$(BUILD)/sw.js

include $(TOP)/py/mkrules.mk

Expand Down
3 changes: 3 additions & 0 deletions src/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type Stage = "local" | "REVIEW" | "STAGING" | "PRODUCTION";

export const stage = (process.env.STAGE || "local") as Stage;
56 changes: 56 additions & 0 deletions src/flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Stage, stage as stageFromEnvironment } from "./environment";

/**
* A union of the flag names (alphabetical order).
*/
export type Flag =
/**
* Enables service worker registration.
*
* Registers the service worker and enables offline use.
*/
"sw";

interface FlagMetadata {
defaultOnStages: Stage[];
name: Flag;
}

const allFlags: FlagMetadata[] = [{ name: "sw", defaultOnStages: [] }];

type Flags = Record<Flag, boolean>;

const flagsForParams = (stage: Stage, params: URLSearchParams) => {
const enableFlags = new Set(params.getAll("flag"));
const allFlagsDefault = enableFlags.has("none")
? false
: enableFlags.has("*")
? true
: undefined;
return Object.fromEntries(
allFlags.map((f) => [
f.name,
isEnabled(f, stage, allFlagsDefault, enableFlags.has(f.name)),
])
) as Flags;
};

const isEnabled = (
f: FlagMetadata,
stage: Stage,
allFlagsDefault: boolean | undefined,
thisFlagOn: boolean
): boolean => {
if (thisFlagOn) {
return true;
}
if (allFlagsDefault !== undefined) {
return allFlagsDefault;
}
return f.defaultOnStages.includes(stage);
};

export const flags: Flags = (() => {
const params = new URLSearchParams(window.location.search);
return flagsForParams(stageFromEnvironment, params);
})();
43 changes: 43 additions & 0 deletions src/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createMessageListener,
Notifications,
} from "./board";
import { flags } from "./flags";

declare global {
interface Window {
Expand All @@ -15,6 +16,48 @@ declare global {
}
}

function initServiceWorker() {
window.addEventListener("load", () => {
navigator.serviceWorker.register("sw.js").then(
(registration) => {
console.log("Simulator service worker registration successful");
// Reload the page when a new service worker is installed.
registration.onupdatefound = function () {
const installingWorker = registration.installing;
if (installingWorker) {
installingWorker.onstatechange = function () {
if (
installingWorker.state === "installed" &&
navigator.serviceWorker.controller
) {
window.location.reload();
}
};
}
};
},
(error) => {
console.error(`Simulator service worker registration failed: ${error}`);
}
);
});
}

if ("serviceWorker" in navigator) {
if (flags.sw) {
initServiceWorker();
} else {
navigator.serviceWorker.getRegistrations().then((registrations) => {
if (registrations.length > 0) {
// We should only have one service worker to unregister.
registrations[0].unregister().then(() => {
window.location.reload();
});
}
});
}
}

const fs = new FileSystem();
const board = createBoard(new Notifications(window.parent), fs);
window.addEventListener("message", createMessageListener(board));
51 changes: 51 additions & 0 deletions src/sw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/// <reference lib="WebWorker" />
// Empty export required due to --isolatedModules flag in tsconfig.json
export type {};
declare const self: ServiceWorkerGlobalScope;
declare const clients: Clients;

const assets = ["simulator.html", "build/simulator.js", "build/firmware.js", "build/firmware.wasm"];
const cacheName = `simulator-${process.env.VERSION}`;

self.addEventListener("install", (event) => {
console.log("Installing simulator service worker...");
self.skipWaiting();
event.waitUntil(
(async () => {
const cache = await caches.open(cacheName);
await cache.addAll(assets);
})()
);
});

self.addEventListener("activate", (event) => {
console.log("Activating simulator service worker...");
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names.map((name) => {
if (/^simulator-/.test(name) && name !== cacheName) {
return caches.delete(name);
}
})
);
await clients.claim();
})()
);
});

self.addEventListener("fetch", (event) => {
event.respondWith(
(async () => {
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
const response = await fetch(event.request);
const cache = await caches.open(cacheName);
cache.put(event.request, response.clone());
return response;
})()
);
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "es2019",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": ["dom", "dom.iterable", "esnext", "WebWorker"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
Expand Down

0 comments on commit 9d81538

Please sign in to comment.