Engineering Journal
Ginexys
Ginexys

Vscode Extension Howto

2026-05-22

How to Ship a VS Code Extension That Wraps an Existing Web Tool

_A technical walkthrough of the Ginexys extension architecture_


If you've built browser-based developer tools and want to bring them to VS Code, this is the pattern I used. It avoids the most common traps and doesn't require rewriting your tool.

The core problem

VS Code webviews are sandboxed iframes with strict Content Security Policy. They don't run on localhost. They don't have window.parent. Paths like src="assets/js/app.js" don't resolve — everything needs a vscode-webview:// URI.

If your tool uses any of these, it will silently break:


Step 1: Replace your embedding detection bridge

If your tool detects embedding via window.parent !== window, that check is always false in a VS Code webview. Replace the bridge entirely:

// vsc-bridge.js — injected INSTEAD of your existing bridge.js
(function () {
  const vscode = acquireVsCodeApi();

window.CwsBridge = { isConnected: true, isEmbedded: true,

send(type, payload) { vscode.postMessage({ type, payload, __ginexys: true }); },

onData(cb) { / wire message listener / },

requestStore(value) { // return Promise that resolves via reply correlation } }; })();

The key: acquireVsCodeApi() is injected by VS Code into every webview. Use it instead of window.parent.


Step 2: Build an HTML rewriter

Your tool's HTML has paths that won't work in a webview. Write a function that rewrites them at open time:

// Absolute paths: src="/assets/js/app.js" → vscode-webview://...
result = result.replace(/(src|href)="(\/[^"]+)"/g, (match, attr, absPath) => {
  const localPath = path.join(portfolioRoot, absPath);
  return ${attr}="${webview.asWebviewUri(vscode.Uri.file(localPath))}";
});

// Relative paths: src="src/js/tifany.js" → vscode-webview://... result = result.replace( /(src|href)="(?!https?:\/\/|vscode-|data:|blob:|#|\/)([^"]+)"/g, (match, attr, relPath) => { const localPath = path.join(toolRoot, relPath); return ${attr}="${webview.asWebviewUri(vscode.Uri.file(localPath))}"; } );

// Replace bridge.js src with vsc-bridge.js result = result.replace( /src="[^"]*\/assets\/os\/bridge\.js"/g, src="${vscBridgeUri}" );

Also inject a CSP meta tag that includes your CDN sources:

const csp = <meta http-equiv="Content-Security-Policy" content="
  default-src 'none';
  script-src 'nonce-${nonce}' ${webview.cspSource} https://cdn.jsdelivr.net https://cdnjs.cloudflare.com;
  style-src 'unsafe-inline' ${webview.cspSource} https://cdn.jsdelivr.net;
  font-src ${webview.cspSource} data: https://cdnjs.cloudflare.com;
  img-src ${webview.cspSource} data: blob: https:;
  worker-src blob:;
">;

Step 3: Create the Custom Editor Provider

export class TafneEditorProvider implements vscode.CustomTextEditorProvider {
  async resolveCustomTextEditor(
    document: vscode.TextDocument,
    panel: vscode.WebviewPanel
  ): Promise<void> {
    panel.webview.options = {
      enableScripts: true,
      localResourceRoots: [vscode.Uri.file(this.portfolioRoot)]
    };

// Read tool HTML, inject initial file content, rewrite paths let html = fs.readFileSync(path.join(this.portfolioRoot, 'tools/tafne/index.html'), 'utf8');

const nonce = crypto.randomUUID().replace(/-/g, ''); html = html.replace('</body>', &lt;script nonce="${nonce}"&gt; window.__GINEXYS_INITIAL_FILE__ = ${JSON.stringify({ content: document.getText(), ext: path.extname(document.fileName) })}; &lt;/script&gt;&lt;/body&gt; );

panel.webview.html = rewriteHtmlForWebview({ html, webview: panel.webview, ... }); } }

Register it in package.json:

"contributes": {
  "customEditors": [{
    "viewType": "yourext.toolname",
    "displayName": "Your Tool Name",
    "selector": [
      { "filenamePattern": "*.csv" },
      { "filenamePattern": "*.json" }
    ],
    "priority": "option"
  }]
}

"priority": "option" means VS Code asks the user first — it won't hijack the default editor.


Step 4: Add mode commands with context menu wiring

Don't make users hunt through menus. Wire commands to the Explorer right-click context:

"contributes": {
  "commands": [
    { "command": "yourext.openNodeEditor", "title": "Open Node Editor", "category": "YourExt" }
  ],
  "menus": {
    "explorer/context": [{
      "command": "yourext.open",
      "when": "resourceExtname == .csv || resourceExtname == .json",
      "group": "navigation@10"
    }]
  }
}

For mode commands, use a static class property to pass intent through VS Code's editor framework:

// Command handler
YourEditorProvider.pendingMode = 'node-editor';
vscode.commands.executeCommand('vscode.openWith', uri, 'yourext.toolname');

// In resolveCustomTextEditor const mode = YourEditorProvider.pendingMode; YourEditorProvider.pendingMode = null; // inject into webview HTML: window.__INITIAL_MODE__ = mode


Step 5: Live sync without the performance trap

If you want VS Code text editor → tool live sync, debounce it and update in-place:

// Extension host — debounced, structured
let debounceTimer: NodeJS.Timeout | undefined;
vscode.workspace.onDidChangeTextDocument(e => {
  if (e.document.uri.toString() !== document.uri.toString()) return;
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(() => {
    panel.webview.postMessage({
      type: 'tool:document-changed',
      payload: { sheets: extractSheets(e.document.getText(), ext) }
    });
  }, 300);
});
// Tool JS — patch existing state, don't create new tabs
window.addEventListener('message', e => {
  if (e.data?.type !== 'tool:document-changed') return;
  e.data.payload.sheets.forEach((data, i) => {
    const parsed = parseForFormat(data.content, data.format);
    if (i < window.sheets.length) {
      // Update in-place
      window.sheets[i].rawHtml = parsed;
      if (window.sheets[i].id === window.activeSheetId) {
        rerenderActiveSheet(parsed);
      }
    } else {
      // Add new sheet entry directly
      window.sheets.push({ id: newId(), rawHtml: parsed, name: data.name });
    }
  });
});

The key rule: never call your "add sheet" function on live sync. It always creates a new entry. Call your render functions directly instead.


The package name trap

VS Code extension IDs are publisher.name. If your pnpm workspace package is "name": "my-tool-core" but satellites declare "extensionDependencies": ["mypublisher.core"], VS Code loads both but can't match them. Rename your package to "name": "core" so the ID resolves to mypublisher.core.


Checklist

Read this post in the full Engineering Journal →