From 7e1f13125be3abc5029815135234acb27aa43b27 Mon Sep 17 00:00:00 2001 From: Anders Hafreager Date: Sat, 11 Jul 2026 08:58:55 +0200 Subject: [PATCH 1/3] Fix Notebook tab blocked by COEP (jupyter iframe refused to connect) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-origin isolation (COOP/COEP via coi-serviceworker, added for the KOKKOS wasm build) broke the Notebook tab in production: a COEP page may only embed iframes whose responses also carry a COEP header. The JupyterLite iframe is controlled by JupyterLite's own service worker — its scope (jupyter/) is more specific than the coi shim's, so its responses had no COEP header and Chrome blocked the frame with ERR_BLOCKED_BY_RESPONSE (blocked:COEP). It worked only on the very first visit, before JupyterLite's service worker had registered. Fix: post-process the built jupyter/service-worker.js to inject COOP/COEP headers on every response it serves (wrapping its single GET response path, maybeFromCache), wired into the deploy workflow. Also nudge an already-registered stale JupyterLite service worker to update on page load, so existing visitors recover on their first visit after deploy instead of the second. Verified in headless Chrome against a static server with no headers (GitHub Pages conditions): stock service worker reproduces the block on the second load; patched build loads the notebook on every visit, and a profile with the stale service worker recovers immediately. Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy.yaml | 2 + .gitignore | 3 ++ index.html | 23 +++++++++++ scripts/patch-jupyterlite-sw.mjs | 70 ++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 scripts/patch-jupyterlite-sw.mjs 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/index.html b/index.html index f2a7762d..4489a216 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,29 @@ window.coi = { coepCredentialless: () => true }; + + + diff --git a/scripts/patch-jupyterlite-sw.mjs b/scripts/patch-jupyterlite-sw.mjs new file mode 100644 index 00000000..e98d3fd4 --- /dev/null +++ b/scripts/patch-jupyterlite-sw.mjs @@ -0,0 +1,70 @@ +/** + * 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). + * + * 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 */"; + +// The wrapped function is the single exit point for every GET response the +// JupyterLite service worker serves (both cache hits and network fetches), +// including the iframe document navigation itself. +const WRAPPED_FUNCTION = "maybeFromCache"; + +const PATCH = ` +${MARKER} +const atomifyWithCoiHeaders = (response) => { + if ( + !response || + response.status === 0 || + response.type === "opaque" || + response.type === "opaqueredirect" + ) { + return response; + } + const headers = new Headers(response.headers); + headers.set("Cross-Origin-Embedder-Policy", "credentialless"); + headers.set("Cross-Origin-Opener-Policy", "same-origin"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; +${WRAPPED_FUNCTION} = ( + (original) => async (event) => + atomifyWithCoiHeaders(await original(event)) +)(${WRAPPED_FUNCTION}); +`; + +const source = readFileSync(swPath, "utf8"); + +if (source.includes(MARKER)) { + console.log(`${swPath} already patched, skipping`); + process.exit(0); +} + +if (!source.includes(`function ${WRAPPED_FUNCTION}(`)) { + throw new Error( + `${swPath} does not define ${WRAPPED_FUNCTION}() — the JupyterLite ` + + "service worker changed upstream; update scripts/patch-jupyterlite-sw.mjs", + ); +} + +writeFileSync(swPath, source + PATCH); +console.log(`Patched ${swPath} with COOP/COEP header injection`); From 0208e546781fe0220ac92af1119646f66ff6f7da Mon Sep 17 00:00:00 2001 From: Anders Hafreager Date: Sat, 11 Jul 2026 09:08:34 +0200 Subject: [PATCH 2/3] Address review + deterministic service worker recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (gemini): construct patched responses with a null body for null-body statuses (204/205/304 etc.), preserve url/redirected, and catch reg.update() rejections. Also wrap the header injection in try/catch so an unexpected response shape can never crash the JupyterLite service worker's fetch handler. Deterministic updates: the page now calls reg.update() on ALL service worker registrations (coi shim + JupyterLite) on every visit — both workers use skipWaiting + clients.claim, so a deployed fix takes over on the next load instead of waiting for browser update heuristics. As a last-resort escape hatch, /atomify/?sw-reset unregisters every service worker on the origin, clears CacheStorage, and reloads clean (documented in README). Verified in headless Chrome under GitHub Pages conditions: stale-broken- SW profile recovers on first visit after deploy; ?sw-reset unsticks a wedged profile even with the broken worker still deployed. Co-Authored-By: Claude Fable 5 --- README.md | 7 ++++ index.html | 55 +++++++++++++++++++++++--------- scripts/patch-jupyterlite-sw.mjs | 35 +++++++++++++++----- 3 files changed, 74 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index db60badb..0350d724 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,15 @@ 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 every +service worker and clears their caches for a fresh start. + ### Run locally `npm run start` diff --git a/index.html b/index.html index 4489a216..8c9bcf92 100644 --- a/index.html +++ b/index.html @@ -19,29 +19,54 @@ Must load before the app module. --> diff --git a/scripts/patch-jupyterlite-sw.mjs b/scripts/patch-jupyterlite-sw.mjs index e98d3fd4..07b1dfa5 100644 --- a/scripts/patch-jupyterlite-sw.mjs +++ b/scripts/patch-jupyterlite-sw.mjs @@ -37,14 +37,33 @@ const atomifyWithCoiHeaders = (response) => { ) { return response; } - const headers = new Headers(response.headers); - headers.set("Cross-Origin-Embedder-Policy", "credentialless"); - headers.set("Cross-Origin-Opener-Policy", "same-origin"); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); + 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 = [101, 103, 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. + return response; + } }; ${WRAPPED_FUNCTION} = ( (original) => async (event) => From e3b1e5635ef793dba85ae41f778d8146a5a7d437 Mon Sep 17 00:00:00 2001 From: Anders Hafreager Date: Sat, 11 Jul 2026 09:25:09 +0200 Subject: [PATCH 3/3] Address code review: scope sw-reset to /atomify/, harden SW patch - ?sw-reset only unregisters service workers scoped under /atomify/ and deletes JupyterLite's "precache" cache, instead of wiping the whole andeplane.github.io origin (GitHub Pages projects share it). - src/index.tsx skips booting the app during a ?sw-reset pass, so the reset no longer races app startup (wasm downloads, non-isolated boot) before the clean reload. - The service worker patch now wraps FetchEvent.prototype.respondWith instead of monkey-patching the internal minified maybeFromCache binding: every response exit point gets the COEP headers and the patch no longer depends on an upstream internal name. Reconstruction failures are logged via console.warn instead of swallowed silently. - Re-running the patch script replaces a previously applied patch (truncate at marker) instead of skipping, so template edits always take effect. - The update-nudge and reset logic live in one script block; the nudge is skipped while resetting and getRegistrations() rejections are caught. Re-verified in headless Chrome under GitHub Pages conditions: stale broken-SW profile recovers on first visit after deploy; ?sw-reset unsticks a wedged profile and lands on a clean URL with crossOriginIsolated intact. Typecheck and vitest (430 tests) pass. Co-Authored-By: Claude Fable 5 --- README.md | 5 +- index.html | 91 ++++++++++++++++++-------------- scripts/patch-jupyterlite-sw.mjs | 48 ++++++++++------- src/index.tsx | 35 +++++++----- 4 files changed, 105 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 0350d724..f06b5d81 100644 --- a/README.md +++ b/README.md @@ -167,8 +167,9 @@ 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 every -service worker and clears their caches for a fresh start. +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 diff --git a/index.html b/index.html index 8c9bcf92..25733c7a 100644 --- a/index.html +++ b/index.html @@ -18,12 +18,26 @@ 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 index 07b1dfa5..2a0e26aa 100644 --- a/scripts/patch-jupyterlite-sw.mjs +++ b/scripts/patch-jupyterlite-sw.mjs @@ -10,6 +10,11 @@ * 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] */ @@ -21,16 +26,11 @@ const swPath = join(outputDir, "service-worker.js"); const MARKER = "/* atomify-coi-patch */"; -// The wrapped function is the single exit point for every GET response the -// JupyterLite service worker serves (both cache hits and network fetches), -// including the iframe document navigation itself. -const WRAPPED_FUNCTION = "maybeFromCache"; - const PATCH = ` ${MARKER} const atomifyWithCoiHeaders = (response) => { if ( - !response || + !(response instanceof Response) || response.status === 0 || response.type === "opaque" || response.type === "opaqueredirect" @@ -42,7 +42,7 @@ const atomifyWithCoiHeaders = (response) => { 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 = [101, 103, 204, 205, 304].includes(response.status) + const body = [204, 205, 304].includes(response.status) ? null : response.body; const patched = new Response(body, { @@ -62,28 +62,38 @@ const atomifyWithCoiHeaders = (response) => { } 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; } }; -${WRAPPED_FUNCTION} = ( - (original) => async (event) => - atomifyWithCoiHeaders(await original(event)) -)(${WRAPPED_FUNCTION}); +const atomifyOriginalRespondWith = FetchEvent.prototype.respondWith; +FetchEvent.prototype.respondWith = function (response) { + return atomifyOriginalRespondWith.call( + this, + Promise.resolve(response).then(atomifyWithCoiHeaders), + ); +}; `; -const source = readFileSync(swPath, "utf8"); +let source = readFileSync(swPath, "utf8"); -if (source.includes(MARKER)) { - console.log(`${swPath} already patched, skipping`); - process.exit(0); -} - -if (!source.includes(`function ${WRAPPED_FUNCTION}(`)) { +if (!source.includes('addEventListener("fetch"')) { throw new Error( - `${swPath} does not define ${WRAPPED_FUNCTION}() — the JupyterLite ` + + `${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( - - - , -);