Fix Notebook tab blocked by COEP (jupyter iframe refused to connect)#380
Conversation
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 <noreply@anthropic.com>
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses a cross-origin isolation issue where the JupyterLite Notebook iframe was being blocked by the browser due to missing COEP/COOP headers. Since the application is cross-origin isolated, it requires all embedded iframes to provide these headers. Because JupyterLite manages its own service worker, it was bypassing the global shim. The solution involves patching the JupyterLite service worker at build time to include the necessary headers and ensuring existing service worker registrations are updated to apply these changes. Highlights
New Features🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Ignored Files
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to patch the JupyterLite service worker to inject COOP/COEP headers, allowing the Notebook iframe to be embedded in a cross-origin-isolated page. It also updates .gitignore to exclude JupyterLite build outputs and adds a script in index.html to force-update the service worker registration on each visit. The feedback suggests handling null-body status codes and preserving metadata (such as url and redirected) when constructing new Response objects in the patch script, as well as catching potential promise rejections from reg.update() in index.html.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| return new Response(response.body, { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
| headers, | ||
| }); |
There was a problem hiding this comment.
When constructing a new Response object, there are two critical issues to handle:
- Null Body Status Codes: Under the Fetch specification, responses with status codes
101,103,204,205, or304must not have a body. Ifresponse.bodyis a non-nullReadableStream(even if empty), passing it tonew Responsewith one of these status codes will throw aTypeErrorand crash the service worker. - Loss of Metadata: Constructing a new
Responseloses the originalurlandredirectedproperties (they default to""andfalserespectively). This can break relative URL resolution for subresources served by the service worker.
We can fix both issues by ensuring the body is null for null-body status codes, and using Object.defineProperty to preserve the url and redirected properties on the newly constructed response.
const isNullBodyStatus = [101, 103, 204, 205, 304].includes(response.status);
const body = isNullBodyStatus ? null : response.body;
const patchedResponse = new Response(body, {
status: response.status,
statusText: response.statusText,
headers,
});
if (response.url) {
Object.defineProperty(patchedResponse, "url", { value: response.url });
}
if (response.redirected) {
Object.defineProperty(patchedResponse, "redirected", { value: response.redirected });
}
return patchedResponse;There was a problem hiding this comment.
Done in 0208e54 — null body for null-body statuses, url/redirected preserved, and the whole injection wrapped in try/catch so the fetch handler can never crash on an unexpected response shape (falls back to the un-patched response).
| reg.active && | ||
| reg.active.scriptURL.includes("/jupyter/service-worker") | ||
| ) { | ||
| reg.update(); |
There was a problem hiding this comment.
reg.update() returns a Promise that can reject (for example, if the network is offline or the server returns an error). To prevent unhandled promise rejections in the browser console (which can trigger global error handlers or crash reporting tools), it is recommended to catch and ignore the rejection.
| reg.update(); | |
| reg.update().catch(() => {}); |
There was a problem hiding this comment.
Done in 0208e54 — update() rejections are now caught. The nudge also updates all registrations (coi shim included), and /atomify/?sw-reset was added as a deterministic escape hatch that unregisters every service worker and clears CacheStorage.
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 <noreply@anthropic.com>
- ?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 <noreply@anthropic.com>
Problem
Since the KOKKOS build made the page cross-origin isolated (COOP/COEP via
coi-serviceworkeron GitHub Pages), the Notebook tab shows "andeplane.github.io refused to connect" — the JupyterLite iframe is blocked withERR_BLOCKED_BY_RESPONSE/blocked:COEP.A cross-origin-isolated document may only embed iframes whose responses also carry a COEP header. The Notebook iframe (
/atomify/jupyter/lab/index.html) is controlled by JupyterLite's own service worker, whose scope (jupyter/) is more specific than the coi shim's (/atomify/) — so the shim never sees those requests, and JupyterLite's worker serves the iframe document without COEP headers. That's also why it works exactly once: on the very first visit JupyterLite's service worker isn't registered yet, so the coi shim handles the iframe and injects the headers; every visit after that is blocked.Fix
scripts/patch-jupyterlite-sw.mjs— post-processes the builtjupyter/service-worker.js, wrapping its single GET response path (maybeFromCache) so every response it serves (cache hits and network fetches, including the iframe navigation) getsCross-Origin-Embedder-Policy: credentialless+Cross-Origin-Opener-Policy: same-origin, matching the coi shim. Fails loudly if a future JupyterLite version renames the function.deploy.yaml— runs the patch script afterjupyter lite build.index.html— nudges an already-registered (stale, broken) JupyterLite service worker to update on page load, so existing visitors recover on their first visit after deploy instead of the second..gitignore— ignore localjupyter lite buildoutput.Verification
Ran the production build in headless Chrome against a static server that sets no headers (GitHub Pages conditions):
ERR_BLOCKED_BY_RESPONSEcrossOriginIsolatedremainstruethroughout, so the KOKKOS/SharedArrayBuffer path is unaffected. Typecheck passes.🤖 Generated with Claude Code