Electron 31 + custom app:// protocol — panoramas return 200 OK net::ERR_FAILED on every load (three.js <img crossOrigin="anonymous">)

What I’m building
A fully offline Electron desktop app (Windows, packaged via electron-builder) that ships a single 360° virtual tour. The React/Vite UI lives in dist/ (inside app.asar), and ~300 panorama PNGs + an .obj model live in a tour_assets/ folder next to the .exe so they can be swapped without rebuilding the installer.

The renderer loads via mainWindow.loadURL(“app://linea/”). Three.js’s TextureLoader then fetches each panorama from app://linea/tour_assets/Render_XXXX.png.

What happens
Every panorama request fails in the renderer with the same pattern:

GET app://linea/tour_assets/Render_0001.png net::ERR_FAILED 200 (OK)
The status is 200 (OK) — the file is being found and served — but Chromium ultimately rejects the response with ERR_FAILED. Three.js’s ImageLoader then retries forever and the tour never starts.

DevTools → Network → clicking the failed request → “Headers” tab → only shows:

Failed to load response data: No data found for resource with given identifier
So I can’t see the actual response headers either.

The file is definitely on disk and readable:

D:…\tour_assets\Render_0001.png exists, ~20 MB, valid 8K equirectangular PNG
JavaScript bundle, CSS, and index.html all load fine over app:// — only the loads (which three.js does with crossOrigin=“anonymous”) fail.

Environment
electron: ^31.3.0
OS: Windows 11
Renderer entry: app://linea/ (HashRouter)
Three.js: ^0.160.1 — uses TextureLoader which sets crossOrigin=“anonymous” on the underlying by default
Package built with vite build → dist/ lives inside app.asar; tour_assets/ ships beside the .exe
What I’ve already tried (in electron/main.js)
All four approaches return 200 OK followed by net::ERR_FAILED for requests:

protocol.handle(“app”, req => new Response(buffer, { headers: { “Content-Type”: “image/png”, “Access-Control-Allow-Origin”: “*”, “Content-Length”: …, “Cache-Control”: “no-store” } })) — fresh fs.promises.readFile per request so no Buffer reuse / detach.
protocol.handle(“app”, req => net.fetch(pathToFileURL(filePath).toString())) — Electron’s recommended modern path.
protocol.registerFileProtocol(“app”, (req, cb) => cb(filePath)) — the deprecated-but-stable API that just hands Chromium the file path.
Setting webSecurity: false in webPreferences (last resort) — still fails the same way.
I’ve also tried, in every variant:

protocol.registerSchemesAsPrivileged([{ scheme: “app”, privileges: { standard, secure, supportFetchAPI, stream, corsEnabled, bypassCSP } }]) registered at module top-level (before app.whenReady).
app.commandLine.appendSwitch(“disable-features”, “BlockInsecurePrivateNetworkRequests”).
session.defaultSession.clearStorageData({ storages: [“serviceworkers”, “shadercache”, “cachestorage”] }) on launch, in case a previous failed response was being cached.
Toggling sandbox, contextIsolation, nodeIntegration.
JS/CSS/HTML all load — only panorama loads fail. Switching the renderer to load via file:// directly is something I’d like to avoid because vite’s relative URLs and tour_assets being external to the asar both get awkward.

What I’m hoping to figure out
Why does net::ERR_FAILED follow a 200 on Electron 31 / Windows when the response body is fully provided, with explicit CORS + MIME headers + privileged scheme?
Is there a known Electron 31 issue with crossOrigin=“anonymous” requests against a custom standard + secure + corsEnabled + bypassCSP scheme that I’m hitting?
If webSecurity: false doesn’t fix it either, what’s left? Is there something I should be doing in webRequest.onHeadersReceived to inject CORS headers post-protocol-handler?
I’ll attach my current electron/main.js and the exact console output below.

If anyone needs more, I can upload a src.zip of the full project (React renderer + Electron main + vite config) — just ask. Thanks in advance.

Is it a case sensitivity problem?
I think web on windows has loose case requirements, and linux+mac has strong case requirements, and maybe electron has strong requirements?

i.e. make sure you’re not loading .PNG when it’s named .png.

Thanks for the reply! Case sensitivity was worth ruling out, but in my case the extension was already lowercase.

The actual fix was returning a ReadableStream instead of a raw Buffer in protocol.handle:

new Response(new ReadableStream({ start(c) { c.enqueue(new Uint8Array(data)); c.close(); } }), { headers: { ‘Content-Type’: ‘image/png’, ‘Access-Control-Allow-Origin’: ‘*’ } })

Also had to add stream: true to registerSchemesAsPrivileged. Turns out Electron 31 / Chromium rejects crossOrigin=“anonymous” image loads when the response body is a raw Buffer, wrapping it in a ReadableStream fixes the CORS body-read path. The 200 → ERR_FAILED pattern is specific to that.