diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 47000ea3..004ff027 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -26,6 +26,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: "20.x" + - name: Patch JupyterLite service worker (COOP/COEP for the Notebook iframe) + run: node scripts/patch-jupyterlite-sw.mjs - name: Install Packages run: npm install - name: Run Typecheck diff --git a/.gitignore b/.gitignore index d242e5c4..6084dc11 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules cpp/lammps cpp/obj /dist +# JupyterLite build output (built in CI, or locally via `jupyter lite build`) +/public/jupyter +.jupyterlite.doit.db .DS_Store cpp/lammps.mjs cpp/lammps.wasm diff --git a/README.md b/README.md index db60badb..f06b5d81 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,16 @@ Build JupyterLite ``` jupyter lite build --contents jupyterlite/content --output-dir public/jupyter +node scripts/patch-jupyterlite-sw.mjs ``` +The second command patches the JupyterLite service worker to inject the +COOP/COEP headers the cross-origin-isolated app requires (the deploy +workflow does the same). If a deployed service worker ever misbehaves, +visiting https://andeplane.github.io/atomify/?sw-reset unregisters +Atomify's service workers and deletes JupyterLite's response cache for a +fresh start (notebook contents are stored in IndexedDB and are preserved). + ### Run locally `npm run start` diff --git a/index.html b/index.html index f2a7762d..25733c7a 100644 --- a/index.html +++ b/index.html @@ -18,8 +18,67 @@ requiring CORP headers. In dev, vite sets the same headers directly. Must load before the app module. --> + diff --git a/scripts/patch-jupyterlite-sw.mjs b/scripts/patch-jupyterlite-sw.mjs new file mode 100644 index 00000000..2a0e26aa --- /dev/null +++ b/scripts/patch-jupyterlite-sw.mjs @@ -0,0 +1,99 @@ +/** + * Patch the built JupyterLite service worker to inject COOP/COEP headers. + * + * The Atomify page is cross-origin isolated (COOP/COEP, required for + * SharedArrayBuffer in the KOKKOS wasm build). A cross-origin-isolated + * document may only embed iframes whose responses also carry a COEP + * header. GitHub Pages cannot set response headers, and the Notebook + * iframe (/jupyter/lab/) is controlled by JupyterLite's own service + * worker — its scope is more specific than the coi-serviceworker shim's, + * so the shim never sees those requests. Without this patch the browser + * blocks the iframe with ERR_BLOCKED_BY_RESPONSE (blocked:COEP). + * + * The patch wraps FetchEvent.prototype.respondWith so every response the + * service worker serves gets the headers, regardless of which internal + * code path produced it. Re-running after editing the template below + * replaces a previously applied patch. + * + * Run after `jupyter lite build` (see .github/workflows/deploy.yaml): + * node scripts/patch-jupyterlite-sw.mjs [path/to/jupyter/output] + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const outputDir = process.argv[2] ?? "public/jupyter"; +const swPath = join(outputDir, "service-worker.js"); + +const MARKER = "/* atomify-coi-patch */"; + +const PATCH = ` +${MARKER} +const atomifyWithCoiHeaders = (response) => { + if ( + !(response instanceof Response) || + response.status === 0 || + response.type === "opaque" || + response.type === "opaqueredirect" + ) { + return response; + } + try { + const headers = new Headers(response.headers); + headers.set("Cross-Origin-Embedder-Policy", "credentialless"); + headers.set("Cross-Origin-Opener-Policy", "same-origin"); + // Null-body statuses reject a body stream in the Response constructor. + const body = [204, 205, 304].includes(response.status) + ? null + : response.body; + const patched = new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }); + // The Response constructor resets url/redirected; restore them for + // consumers that read them. + if (response.url) { + Object.defineProperty(patched, "url", { value: response.url }); + } + if (response.redirected) { + Object.defineProperty(patched, "redirected", { value: true }); + } + return patched; + } catch (error) { + // Never wedge the service worker on an unexpected response shape; + // worst case the un-patched response re-blocks the notebook iframe. + console.warn( + "atomify-coi-patch: could not inject COOP/COEP headers", + error, + ); + return response; + } +}; +const atomifyOriginalRespondWith = FetchEvent.prototype.respondWith; +FetchEvent.prototype.respondWith = function (response) { + return atomifyOriginalRespondWith.call( + this, + Promise.resolve(response).then(atomifyWithCoiHeaders), + ); +}; +`; + +let source = readFileSync(swPath, "utf8"); + +if (!source.includes('addEventListener("fetch"')) { + throw new Error( + `${swPath} does not register a fetch handler — the JupyterLite ` + + "service worker changed upstream; update scripts/patch-jupyterlite-sw.mjs", + ); +} + +const markerIndex = source.indexOf(MARKER); +if (markerIndex !== -1) { + // Already patched (possibly by an older version of this script): + // strip the previous patch so the current template always applies. + source = source.slice(0, markerIndex).trimEnd() + "\n"; + console.log(`${swPath} was already patched, replacing the patch`); +} + +writeFileSync(swPath, source + PATCH); +console.log(`Patched ${swPath} with COOP/COEP header injection`); diff --git a/src/index.tsx b/src/index.tsx index 4bdf7ef6..7185659c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,20 +8,29 @@ import store from "./store"; import mixpanel from "mixpanel-browser"; import { track, getEmbeddingParams } from "./utils/metrics"; -mixpanel.init("b5022dd7fe5b3cd0396d84284ae647e6", { debug: false }); +// index.html's ?sw-reset escape hatch is unregistering the service workers +// and reloads without the flag when done; don't boot the app (and kick off +// wasm downloads) in the meantime. +const swResetting = new URLSearchParams(window.location.search).has( + "sw-reset", +); + +if (!swResetting) { + mixpanel.init("b5022dd7fe5b3cd0396d84284ae647e6", { debug: false }); + + track("Page.Load", getEmbeddingParams()); -track("Page.Load", getEmbeddingParams()); + const container = document.getElementById("root"); + if (!container) { + throw new Error( + "Failed to find the root element. Please ensure an element with id 'root' exists in your index.html.", + ); + } -const container = document.getElementById("root"); -if (!container) { - throw new Error( - "Failed to find the root element. Please ensure an element with id 'root' exists in your index.html.", + const root = createRoot(container); + root.render( + + + , ); } - -const root = createRoot(container); -root.render( - - - , -);