Determine whether JSZip can handle cross-realm Blobs in a Firefox
userscript sandbox (@sandbox JavaScript) without hitting the Xray boundary error:
Error: Accessing TypedArray data over Xrays is slow, and forbidden in order to
encourage performant code. To copy TypedArrays across origin boundaries,
consider using Components.utils.cloneInto().
The gemini-conversation-exporter
userscript added a "ZIP bundle" download strategy that fetches generated files via
pageWindow.fetch() and bundles them into a ZIP archive using
fflate.
On Firefox (Tampermonkey with @sandbox JavaScript), the fetch response's ArrayBuffer lives in
the page realm. Firefox's Xray security model blocks userscript-realm code from accessing
TypedArrays/ArrayBuffers from the page realm.
This gist contains a minimal test userscript that:
- Fetches a binary file (
favicon.ico) viaGM_xmlhttpRequest(extension realm, avoids Xray on fetch) - Gets a
Blobfrom the response - Passes the Blob directly to
zip.file("favicon.ico", blob)— JSZip's documented Blob input API - Calls
zip.generateAsync({ type: "blob" })
| Approach | Result | Why |
|---|---|---|
res.arrayBuffer() |
Xray error | ArrayBuffer is in page realm |
FileReader.readAsArrayBuffer(blob) |
Xray error | FileReader creates ArrayBuffer in page realm (greasemonkey#2034) |
new Response(blob).arrayBuffer() |
Xray error | Blob is still from page realm, Response inherits it |
FileReader.readAsDataURL(blob) → atob() → new Uint8Array() |
Works for fetch | String primitives are never Xrayed; decoding happens in userscript realm |
new TextEncoder().encode(str) |
Xray error | TextEncoder is a page-realm object; returned Uint8Array is in page realm |
Manual UTF-8 encoder → new Uint8Array(bytes) |
Works for text | Avoids TextEncoder; creates Uint8Array in userscript realm |
| Version | Approach | Result |
|---|---|---|
| v0.1–0.2 | unsafeWindow.fetch + @grant none |
unsafeWindow is not defined |
| v0.3 | unsafeWindow.fetch + @grant unsafeWindow |
Permission denied to access property "body" (DOM Xray) |
| v0.4 | unsafeWindow.fetch + auto-run, no button |
Permission denied to access property "body" (fetch Promise Xray) |
| v0.5 | GM_xmlhttpRequest + JSZip Blob input |
Hangs silently — JSZip's generateAsync() never resolves |
JSZip accepts Blob inputs (zip.file("name", blob)), but internally it still needs to read the
Blob's binary data — via arrayBuffer(), FileReader, or similar APIs. When running in Firefox's
@sandbox JavaScript mode, this internal read hits the same Xray boundary.
The zip.generateAsync() Promise hangs silently — the Xray error is swallowed inside JSZip's
internal Promise chain, so no error is reported to the caller.
The core problem is Firefox's Xray security model blocking TypedArray/ArrayBuffer access across realms. Any ZIP library (JSZip, fflate, zip.js) that needs to read binary data from a page-realm Blob will hit this boundary.
The @sandbox JavaScript mode creates a realm boundary that affects every Web API returning or
accepting TypedArrays/DOM objects:
| API | Xray issue? | Workaround |
|---|---|---|
fetch().arrayBuffer() |
Yes | base64 data URL + atob() |
new TextEncoder().encode() |
Yes | Manual UTF-8 encoder |
new Blob([uint8array]) |
Likely | Unknown |
URL.createObjectURL(blob) |
Likely | Unknown |
document.createElement("a") |
Yes | unsafeWindow.document |
link.click() |
Likely | Unknown |
Each workaround only fixes one API — the next Xray issue appears at the next boundary. This whack-a-mole approach is unsustainable.
Switching @sandbox from JavaScript to raw eliminates ALL Xray issues at the root.
@sandbox raw runs the userscript in the page context (MAIN_WORLD), so there is no realm
boundary to cross. TextEncoder, Blob, URL.createObjectURL, fetch.arrayBuffer(),
document.createElement() — all work natively without any workarounds.
This is Tampermonkey's default mode and the mode most userscripts use. The JavaScript sandbox was
originally added to bypass Gemini's CSP, but raw mode also bypasses CSP (Tampermonkey injects
the script in a way that circumvents page CSP).
Verified working in production — v0.7.8 successfully exports ZIP bundles containing:
- Markdown and JSON text files (via
TextEncoder.encode()) - Binary
.docxfiles (viafetch.arrayBuffer()) - ZIP creation (via
fflate.zipSync()) - Download trigger (via
Blob+URL.createObjectURL()+document.createElement("a"))
The JavaScript sandbox mode runs the userscript in Firefox's USERSCRIPT_WORLD — a separate
realm from the page. While this provides isolation, it means every Web API call crosses the
Xray boundary. The only way to share data between realms is via:
- String primitives (never Xrayed) — works but requires manual encoding/decoding
Components.utils.cloneInto()— Firefox-specific, not available in all userscript managerswrappedJSObject— Firefox-specific, bypasses Xray wrapping but is non-standard
None of these are cross-browser solutions. @sandbox raw is the only approach that works
universally across Chrome, Firefox, and Edge without per-API workarounds.
The tradeoff is reduced isolation — page scripts could theoretically interfere with the
userscript. In practice, this is rarely an issue for export tools that only read page data and
create downloads. Most popular userscripts use raw mode.
| Version | Approach | Outcome |
|---|---|---|
| v0.7.4 | base64 fetch workaround | Fixed fetch Xray, but TextEncoder broke |
| v0.7.7 | Manual UTF-8 encoder | Fixed TextEncoder, but Blob/URL/DOM would break next |
| v0.7.8 | @sandbox raw |
All Xray issues eliminated at the root |
- Tampermonkey #2374 — TypedArrays fail to work on JavaScript sandbox
- Greasemonkey #2034 — FileReader API is unusable
- Mozilla Bug 987163 — Implement Xrays to TypedArray objects
- Mozilla Bug 1320519 — subarray() does not work with Xrayed TypedArrays
- noble-ed25519 #120 — Signing fails in Firefox WebExtension due to Uint8Array
- Tampermonkey @sandbox documentation
- Tampermonkey FAQ — What @sandbox value should I use?
- Violentmonkey — Inject scripts into different contexts