Skip to content

Commit 9d81538

Browse files
Add service worker for offline compatibility (#115)
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.
1 parent 6204a0b commit 9d81538

File tree

8 files changed

+159
-5
lines changed

8 files changed

+159
-5
lines changed

.github/workflows/build.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ jobs:
2626
steps:
2727
# Note: This workflow will not run on forks without modification; we're open to making steps
2828
# that rely on our deployment infrastructure conditional. Please open an issue.
29-
- uses: actions/checkout@v3
29+
- uses: actions/checkout@v4
3030
- name: Configure node
31-
uses: actions/setup-node@v3
31+
uses: actions/setup-node@v4
3232
with:
3333
node-version: 20.x
3434
cache: "npm"

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ build:
88

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

1414
watch: dist

src/Makefile

+2-1
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ $(BUILD)/micropython.js: $(OBJ) jshal.js simulator-js
146146
$(Q)emcc $(LDFLAGS) -o $(BUILD)/firmware.js $(OBJ) $(JSFLAGS)
147147

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

151152
include $(TOP)/py/mkrules.mk
152153

src/environment.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export type Stage = "local" | "REVIEW" | "STAGING" | "PRODUCTION";
2+
3+
export const stage = (process.env.STAGE || "local") as Stage;

src/flags.ts

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Stage, stage as stageFromEnvironment } from "./environment";
2+
3+
/**
4+
* A union of the flag names (alphabetical order).
5+
*/
6+
export type Flag =
7+
/**
8+
* Enables service worker registration.
9+
*
10+
* Registers the service worker and enables offline use.
11+
*/
12+
"sw";
13+
14+
interface FlagMetadata {
15+
defaultOnStages: Stage[];
16+
name: Flag;
17+
}
18+
19+
const allFlags: FlagMetadata[] = [{ name: "sw", defaultOnStages: [] }];
20+
21+
type Flags = Record<Flag, boolean>;
22+
23+
const flagsForParams = (stage: Stage, params: URLSearchParams) => {
24+
const enableFlags = new Set(params.getAll("flag"));
25+
const allFlagsDefault = enableFlags.has("none")
26+
? false
27+
: enableFlags.has("*")
28+
? true
29+
: undefined;
30+
return Object.fromEntries(
31+
allFlags.map((f) => [
32+
f.name,
33+
isEnabled(f, stage, allFlagsDefault, enableFlags.has(f.name)),
34+
])
35+
) as Flags;
36+
};
37+
38+
const isEnabled = (
39+
f: FlagMetadata,
40+
stage: Stage,
41+
allFlagsDefault: boolean | undefined,
42+
thisFlagOn: boolean
43+
): boolean => {
44+
if (thisFlagOn) {
45+
return true;
46+
}
47+
if (allFlagsDefault !== undefined) {
48+
return allFlagsDefault;
49+
}
50+
return f.defaultOnStages.includes(stage);
51+
};
52+
53+
export const flags: Flags = (() => {
54+
const params = new URLSearchParams(window.location.search);
55+
return flagsForParams(stageFromEnvironment, params);
56+
})();

src/simulator.ts

+43
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createMessageListener,
88
Notifications,
99
} from "./board";
10+
import { flags } from "./flags";
1011

1112
declare global {
1213
interface Window {
@@ -15,6 +16,48 @@ declare global {
1516
}
1617
}
1718

19+
function initServiceWorker() {
20+
window.addEventListener("load", () => {
21+
navigator.serviceWorker.register("sw.js").then(
22+
(registration) => {
23+
console.log("Simulator service worker registration successful");
24+
// Reload the page when a new service worker is installed.
25+
registration.onupdatefound = function () {
26+
const installingWorker = registration.installing;
27+
if (installingWorker) {
28+
installingWorker.onstatechange = function () {
29+
if (
30+
installingWorker.state === "installed" &&
31+
navigator.serviceWorker.controller
32+
) {
33+
window.location.reload();
34+
}
35+
};
36+
}
37+
};
38+
},
39+
(error) => {
40+
console.error(`Simulator service worker registration failed: ${error}`);
41+
}
42+
);
43+
});
44+
}
45+
46+
if ("serviceWorker" in navigator) {
47+
if (flags.sw) {
48+
initServiceWorker();
49+
} else {
50+
navigator.serviceWorker.getRegistrations().then((registrations) => {
51+
if (registrations.length > 0) {
52+
// We should only have one service worker to unregister.
53+
registrations[0].unregister().then(() => {
54+
window.location.reload();
55+
});
56+
}
57+
});
58+
}
59+
}
60+
1861
const fs = new FileSystem();
1962
const board = createBoard(new Notifications(window.parent), fs);
2063
window.addEventListener("message", createMessageListener(board));

src/sw.ts

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/// <reference lib="WebWorker" />
2+
// Empty export required due to --isolatedModules flag in tsconfig.json
3+
export type {};
4+
declare const self: ServiceWorkerGlobalScope;
5+
declare const clients: Clients;
6+
7+
const assets = ["simulator.html", "build/simulator.js", "build/firmware.js", "build/firmware.wasm"];
8+
const cacheName = `simulator-${process.env.VERSION}`;
9+
10+
self.addEventListener("install", (event) => {
11+
console.log("Installing simulator service worker...");
12+
self.skipWaiting();
13+
event.waitUntil(
14+
(async () => {
15+
const cache = await caches.open(cacheName);
16+
await cache.addAll(assets);
17+
})()
18+
);
19+
});
20+
21+
self.addEventListener("activate", (event) => {
22+
console.log("Activating simulator service worker...");
23+
event.waitUntil(
24+
(async () => {
25+
const names = await caches.keys();
26+
await Promise.all(
27+
names.map((name) => {
28+
if (/^simulator-/.test(name) && name !== cacheName) {
29+
return caches.delete(name);
30+
}
31+
})
32+
);
33+
await clients.claim();
34+
})()
35+
);
36+
});
37+
38+
self.addEventListener("fetch", (event) => {
39+
event.respondWith(
40+
(async () => {
41+
const cachedResponse = await caches.match(event.request);
42+
if (cachedResponse) {
43+
return cachedResponse;
44+
}
45+
const response = await fetch(event.request);
46+
const cache = await caches.open(cacheName);
47+
cache.put(event.request, response.clone());
48+
return response;
49+
})()
50+
);
51+
});

tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"compilerOptions": {
33
"target": "es2019",
4-
"lib": ["dom", "dom.iterable", "esnext"],
4+
"lib": ["dom", "dom.iterable", "esnext", "WebWorker"],
55
"allowJs": true,
66
"skipLibCheck": true,
77
"esModuleInterop": true,

0 commit comments

Comments
 (0)