Engineering Journal
Ginexys
Ginexys

Vscode Worker Debug

2026-05-30

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:

Extension host logs (TypeScript 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:

  1. Never create relay-blobs. Blob-spawned workers don't have webview auth tokens.
  2. Fetch worker source on the main thread. Auth works there.
  3. Wrap the source bytes in a content-blob. Pass that to new Worker().
  4. Pre-fetch all workers at page init. Store in a map. Worker() reads synchronously.
  5. Add ${panel.webview.cspSource} to connect-src. Without it, main-thread fetch(vscode-resource://...) is blocked before auth matters.
  6. If your worker library (pdfjs, Monaco) hardcodes filenames with content hashes, scan assets/ at runtime and map both the stable and hashed names.
The debugging surface area is small once you know what to look for: DevTools Network tab will show you exactly which request returned 401, and the URL tells you whether it's a missing WORKER_MAP entry or a CSP block.

_Part of the Ginexys build-in-public series. Follow the extension development at ginexys.com/blog._

Read this post in the full Engineering Journal →