diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cba9aca9..232f7a5f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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" diff --git a/Makefile b/Makefile index 60df0f9f..85776152 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/src/Makefile b/src/Makefile index 66dbdcba..90a5effc 100644 --- a/src/Makefile +++ b/src/Makefile @@ -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 diff --git a/src/environment.ts b/src/environment.ts new file mode 100644 index 00000000..7d295ca7 --- /dev/null +++ b/src/environment.ts @@ -0,0 +1,3 @@ +export type Stage = "local" | "REVIEW" | "STAGING" | "PRODUCTION"; + +export const stage = (process.env.STAGE || "local") as Stage; diff --git a/src/flags.ts b/src/flags.ts new file mode 100644 index 00000000..2e0a517b --- /dev/null +++ b/src/flags.ts @@ -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; + +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); +})(); diff --git a/src/simulator.ts b/src/simulator.ts index c45f60e3..7b129130 100644 --- a/src/simulator.ts +++ b/src/simulator.ts @@ -7,6 +7,7 @@ import { createMessageListener, Notifications, } from "./board"; +import { flags } from "./flags"; declare global { interface Window { @@ -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)); diff --git a/src/sw.ts b/src/sw.ts new file mode 100644 index 00000000..2126d359 --- /dev/null +++ b/src/sw.ts @@ -0,0 +1,51 @@ +/// +// 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; + })() + ); +}); diff --git a/tsconfig.json b/tsconfig.json index 0716a2c2..40888c08 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "es2019", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": ["dom", "dom.iterable", "esnext", "WebWorker"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true,