Vscode Worker Debug
Why My VS Code Webview Workers Were Returning 401
_Build in public — debugging the PDF Processor extension_
I spent most of a session staring at three errors that felt like they should be unrelated:
Could not create web worker(s). Falling back to loading web worker code in main thread
pdf.worker.mjs: Failed to load resource: the server responded with a status of 401 ()
Error loading PDF 1: Error: Geometry worker crashed: [object Event]
Monaco workers dead. pdfjs in fake-worker mode. My geometry extraction worker crashing with [object Event] — the unhelpful JS way of saying "an error event fired, check yourself".
Every one of these failures had the same root cause. It took me a while to see it.
How VS Code webview auth actually works
VS Code serves extension files over a vscode-resource:// scheme. When you set a <script src="..."> tag to a webview URI, the browser makes a request and VS Code attaches an auth token automatically. The file loads. This works everywhere you'd expect it to: <script src="">, <link href="">, stylesheet url() inside CSS, images.
What it does NOT cover: fetch() calls you make from JavaScript, import() expressions, and importScripts() calls inside workers. And critically — any request made from inside a blob-spawned worker.
The pattern I'd used was what I'd call a relay-blob:
// Create a blob that imports the real script
const relayBlob = new Blob(
[import "${workerUri}";],
{ type: 'application/javascript' }
);
const blobUrl = URL.createObjectURL(relayBlob);
new Worker(blobUrl, { type: 'module' });
The thinking was: create a thin blob worker, have it import the real script from the vscode-resource:// URL. The blob itself has no path that needs auth — it's just blob://. The import inside it goes to the real script.
The problem: the import inside the blob executes in the blob's origin context. blob: origin workers don't have the webview's auth token. So the import "vscode-resource://..." hits VS Code's file server without credentials and returns 401.
The fix is different in kind, not just degree: fetch the script source on the main thread, where auth works, and put the actual bytes into the blob.
Content-blob vs relay-blob
A relay-blob delegates resolution. A content-blob is self-contained.
// Relay-blob (broken):
// blob → import "vscode-resource://..." → 401 (no auth in blob context)
const relay = new Blob(['import "vscode-resource://...workers/geo.js";'], {
type: 'application/javascript'
});
// Content-blob (correct): // fetch source on main thread → bytes → blob → Worker async function fetchBlob(uri) { const r = await fetch(uri); // main thread, auth works const src = await r.text(); return URL.createObjectURL(new Blob([src], { type: 'application/javascript' })); }
The content-blob has zero external dependencies. It doesn't reach out to vscode-resource:// at init time. It just runs.
For my geometry worker — which is a fully self-contained Rollup bundle with all dependencies inlined — this just works. Zero top-level import statements, so there's nothing to resolve against the blob: origin.
The CSP catch
Before this can work, you need one more thing: your CSP's connect-src must allow fetch() to vscode-resource:// URIs on the main thread.
// BEFORE — fetch() blocked by CSP
const csp = connect-src https://ginexys.com ${cdnSources};;
// AFTER — ${panel.webview.cspSource} covers vscode-resource:// const csp = connect-src https://ginexys.com ${panel.webview.cspSource} ${cdnSources};;
Without cspSource in connect-src, the fetch(workerUri) call on the main thread gets blocked before it even sends the request. The auth token doesn't matter — CSP stops it first.
Pre-fetching all workers at page init
Worker() calls happen on user action — typically after the user drops a PDF or triggers processing. But fetchBlob() is async. If we call it at worker spawn time, there's a window where the blob isn't ready yet.
The solution: pre-fetch everything at page init using Promise.all, store the blob URLs in a window.__WORKER_BLOBS__ map keyed by URI, then read synchronously when Worker() fires:
window.__WORKER_BLOBS__ = {};
Promise.all(allWorkerUris.map(uri => fetchBlob(uri).then(blobUrl => { window.__WORKER_BLOBS__[uri] = blobUrl; }) )).then(() => { console.log('[GX] All worker blobs pre-fetched.'); });
Then the patched Worker constructor is synchronous — just a map lookup:
var _Worker = window.Worker;
window.Worker = function PatchedWorker(scriptUrl, opts) {
var entry = WORKER_MAP[getPathname(scriptUrl)];
if (entry) {
var blobUrl = window.__WORKER_BLOBS__[entry.uri];
if (blobUrl) {
return new _Worker(blobUrl, { type: entry.module ? 'module' : 'classic' });
}
console.warn('[GX] Worker blob not ready for', scriptUrl);
}
return new _Worker(scriptUrl, opts);
};
The log line [GX] All worker blobs pre-fetched. shows up in webview DevTools. If you see it before the user loads a file, the timing is safe.
The hashed filename problem
This one took me a while. pdfjs-dist bundles a hardcoded ?url import that resolves to whatever hash Vite puts on the pdf.worker file:
/tools/pdf-processor/assets/pdf.worker-BgryrOlp.mjs
Not pdf.worker.mjs. The hash.
I had the stable filename in WORKER_MAP. pdfjs called new Worker with the hashed filename. My patch didn't recognize it. Fell through. Native Worker got a vscode-resource:// URL. 401. Fake-worker mode.
The fix: scan the assets directory at extension load time and create two entries:
const assetFiles = fs.readdirSync(path.join(assetDir, 'assets'));
const findWorker = (pat: RegExp) => assetFiles.find(f => pat.test(f)) ?? '';
const pdfWorkerHashedFile = findWorker(/^pdf\.worker-[A-Za-z0-9_-]+\.mjs$/);
const WORKER_MAP = { "/tools/pdf-processor/assets/pdf.worker.mjs": { uri: workerPdfUri, module: true }, // Also map the hashed variant — pdfjs-dist hardcodes this name internally ...(pdfWorkerHashedFile ? { [/tools/pdf-processor/assets/${pdfWorkerHashedFile}]: { uri: workerPdfHashedUri, module: true } } : {}) };
Both map to the same content-blob. Same bytes, two keys.
Monaco workers: the synchronous requirement
Monaco calls getWorkerUrl() synchronously. It doesn't return a Promise. It needs the blob URL now.
The pre-fetch pattern handles this: by the time the user opens a Monaco editor (always after page init), __WORKER_BLOBS__ is already populated. getWorkerUrl reads from it synchronously:
self["MonacoEnvironment"] = {
getWorkerUrl: function(moduleId, label) {
var uri = MONACO_MAP[label] || MONACO_MAP["editorWorkerService"];
var blob = window.__WORKER_BLOBS__ && window.__WORKER_BLOBS__[uri];
if (blob) return blob;
// Not ready — return a trampoline that will likely fail,
// but Monaco will degrade gracefully rather than crash.
return URL.createObjectURL(
new Blob(['importScripts(' + JSON.stringify(uri) + ');'], { type: 'application/javascript' })
);
}
};
Classic workers use importScripts, not import. The trampoline blob is fine as a fallback because Monaco's classic workers can tolerate a degraded state. But with pre-fetch, the fallback should never be reached in normal flow.
Where to look when it's not working
The webview DevTools are at Help → Toggle Developer Tools in VS Code. This opens a Chrome DevTools window attached to the webview iframe.
What to look for:
[GX] All worker blobs pre-fetched.in Console → timing is good, blobs are ready[GX] Worker blob not ready for <path>→ WORKER_MAP lookup is working but fetch hasn't resolved yet (shouldn't happen in normal flow)Failed to load resource: 401→ either the path isn't in WORKER_MAP at all, orconnect-srcis blocking the fetch before it fires[object Event]as an error message → worker initialization failed; check Network tab for the underlying request that 401'd
console.log) go to View → Output → Extension Host in VS Code — separate from the webview DevTools.
The pattern summarized
If you're building a VS Code extension that loads Web Workers from webview URIs:
- Never create relay-blobs. Blob-spawned workers don't have webview auth tokens.
- Fetch worker source on the main thread. Auth works there.
- Wrap the source bytes in a content-blob. Pass that to
new Worker(). - Pre-fetch all workers at page init. Store in a map. Worker() reads synchronously.
- Add
${panel.webview.cspSource}toconnect-src. Without it, main-threadfetch(vscode-resource://...)is blocked before auth matters. - If your worker library (pdfjs, Monaco) hardcodes filenames with content hashes, scan
assets/at runtime and map both the stable and hashed names.
_Part of the Ginexys build-in-public series. Follow the extension development at ginexys.com/blog._