I Built a Full HTTP Client Extension for VS Code — Here’s Everything I Learned

DotFetch v1.2.0 — A deep dive into building a professional REST client inside VS Code with Auth, Retry Logic, JSON Highlighting, and a modular ES Modules architecture.”
tags: vscode, typescript, javascript, webdev

cover_image: https://raw.githubusercontent.com/kareem2099/DotFetch/main/media/screenshot-main.png

I’ve been building DotFetch — a VS Code extension that replaces Postman/Insomnia for developers who live inside their editor. Version 1.2.0 just dropped, and I want to share the interesting engineering decisions behind it.

🔗 GitHub | VS Code Marketplace | Open VSX

What is DotFetch?

An HTTP client that lives inside your VS Code sidebar. No context switching, no separate app. Just open the panel and fire requests.

Features shipped in v1.2.0:
✅ Basic Auth (RFC 7617) + Bearer Token (RFC 6750)
✅ JSON Syntax Highlighting
✅ Request Templates
✅ Auto Retry with Linear Backoff
✅ History Search & Filter
✅ Collection Export to JSON
✅ Modular ES Modules architecture

🎥 See it in action (60-second Demo)

Problem 1: VS Code Webviews are sandboxed

The biggest surprise when building a VS Code extension with a Webview: you can’t use window.prompt(), window.alert(), or window.confirm() reliably. They’re blocked in the webview sandbox.

I learned this the hard way when building the Template save feature:

// ❌ This silently does nothing in VS Code webviews
const name = window.prompt('Template name:', defaultName);

The fix? Build a proper modal and communicate via postMessage:

// ✅ Show a real modal instead
function saveAsTemplate() {
    const modal = document.getElementById('template-modal');
    const nameInput = document.getElementById('template-name');

    if (modal) { modal.classList.add('modal-visible'); }
    if (nameInput) { 
        nameInput.value = defaultName; 
        nameInput.focus(); 
        nameInput.select(); 
    }
}

function confirmSaveAsTemplate() {
    const name = document.getElementById('template-name').value.trim();
    if (!name) { 
        vscode.postMessage({ type: 'notify', level: 'error', text: 'Enter a name' }); 
        return; 
    }
    // ... save logic
}

Rule of thumb: Never use browser dialogs in a VS Code webview. Always build custom modals.

Problem 2: The 2000-line script.js monster

After shipping 5 features, media/script.js hit 2000+ lines and was growing fast. I decided to refactor before adding more features — best decision I made.

The Architecture

src/webview/
├── state.js         ← Single shared state object
├── api.js           ← vscode.postMessage wrapper
├── ui.js            ← Tabs, modals, previews, query params
├── auth.js          ← T201, T202
├── highlighting.js  ← T203 JSON highlighting
├── collections.js   ← Collections + Templates (T204)
├── history.js       ← History management
├── curl.js          ← cURL import/export
├── request.js       ← sendRequest, loadForm, save
└── main.js          ← Entry point + message handler

The Shared State Pattern

Instead of scattering state across files, I centralized everything:

// src/webview/state.js
export const state = {
    queryParams: [],
    history: [],
    collections: {},
    expandedCollections: new Set(),
    currentRequest: null,
    settings: { timeout: 10000 },
    environments: [],
    isRequestInProgress: false,
    isUpdatingPreview: false,
    previewTimeout: null,
    authConfig: { type: 'none', username: '', password: '', token: '' }
};

Every module imports state and mutates it directly. Simple, predictable, no prop drilling.

The API Wrapper

// src/webview/api.js
let _vscode = null;

export function initApi(vscode) {
    _vscode = vscode;
}

export function post(message) {
    _vscode.postMessage(message);
}

export function notify(level, text) {
    _vscode.postMessage({ type: 'notify', level, text });
}

Now any module can call notify('error', 'Something went wrong') without knowing about VS Code internals.

esbuild bundles it all

The key insight: esbuild takes the ES Modules source and bundles it into a single media/script.js that VS Code can load.

// package.json
{
  "scripts": {
    "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=out/extension.js --external:vscode --format=cjs --platform=node && esbuild ./src/webview/main.js --bundle --outfile=media/script.js --format=iife --platform=browser"
  }
}

Result: media/script.js went from 2000 lines hand-written to 49.6 KB bundled — and each source file is clean and focused.

Problem 3: Implementing Auto Retry the Right Way

The retry logic lives in extension.ts (Node.js side), not the webview. Here’s the full retry loop:

private async handleRequest(webviewView: vscode.WebviewView, message: any) {
    const startTime = Date.now();
    const maxRetries = Math.min(Math.max(parseInt(message.retryCount) || 0, 0), 5);
    const retryableCodes = ['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNABORTED'];

    let lastError: any = null;

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        // Notify UI about retry progress
        if (attempt > 0) {
            webviewView.webview.postMessage({
                type: 'retryAttempt',
                attempt,
                total: maxRetries
            });

            // Linear backoff: wait attempt * 1000ms
            await new Promise(resolve => setTimeout(resolve, attempt * 1000));
        }

        this.abortController = new AbortController();

        try {
            const response = await axios({ /* ... */ });

            // Success — send response and exit loop
            webviewView.webview.postMessage({
                type: 'response',
                // ...
                attempts: attempt + 1  // Let the UI know how many attempts it took
            });
            return;

        } catch (error: unknown) {
            lastError = error;

            // Don't retry if user cancelled
            if (error instanceof Error && error.name === 'CanceledError') {
                webviewView.webview.postMessage({ 
                    type: 'error', 
                    error: 'Request cancelled',
                    cancelled: true 
                });
                return;
            }

            // Only retry on network errors, not HTTP errors
            const isRetryable = axios.isAxiosError(error) &&
                error.request &&        // Request was made
                !error.response &&      // But no response received
                retryableCodes.includes((error as any).code || '');

            if (!isRetryable || attempt >= maxRetries) { break; }
        }
    }

    // All attempts failed — send descriptive error
    let errorMessage = buildErrorMessage(lastError, maxRetries);
    webviewView.webview.postMessage({ type: 'error', error: errorMessage });
}

Key decisions:

  • Only retry on network-level errors (no response), not HTTP errors (4xx, 5xx). A 404 should not be retried.
  • Linear backoff (attempt * 1000ms) not exponential — simpler and sufficient for transient failures.
  • Max 5 retries, configurable per request.
  • The UI shows Retry 1/2... in the Send button during retries.

Problem 4: XSS-safe JSON Syntax Highlighting

I wanted pretty colored JSON without importing a library. The trick is to escape HTML first, then apply highlighting:

// src/webview/highlighting.js
export function syntaxHighlightJson(json) {
    if (typeof json !== 'string') {
        json = JSON.stringify(json, null, 2);
    }

    // CRITICAL: Escape HTML BEFORE adding spans
    // Otherwise injected HTML in JSON values becomes XSS
    json = json
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;');

    return json.replace(
        /("(\u[a-zA-Z0-9]{4}|\[^u]|[^\"])*"(s*:)?|b(true|false|null)b|-?d+(?:.d*)?(?:[eE][+-]?d+)?)/g,
        (match) => {
            let cls = 'json-number';
            if (/^"/.test(match)) {
                cls = /:$/.test(match) ? 'json-key' : 'json-string';
            } else if (/true|false/.test(match)) {
                cls = 'json-boolean';
            } else if (/null/.test(match)) {
                cls = 'json-null';
            }
            return `<span class="${cls}">${match}</span>`;
        }
    );
}

And the CSS (VS Code dark theme colors):

.json-key     { color: #9cdcfe; }  /* Light blue */
.json-string  { color: #ce9178; }  /* Orange */
.json-number  { color: #b5cea8; }  /* Light green */
.json-boolean { color: #569cd6; }  /* Blue */
.json-null    { color: #569cd6; }  /* Blue */

The colors match VS Code’s own JSON editor — intentional.

Problem 5: Auth without exposing credentials in logs

Basic Auth encoding happens in the browser (webview side), not in the extension. This prevents credentials from appearing in VS Code’s output channel:

// src/webview/auth.js
export function buildAuthHeader() {
    if (state.authConfig.type === 'basic' && state.authConfig.username) {
        // btoa() runs in the webview — never touches extension.ts logs
        const encoded = btoa(`${state.authConfig.username}:${state.authConfig.password}`);
        return `Authorization: Basic ${encoded}`;
    }
    if (state.authConfig.type === 'bearer' && state.authConfig.token) {
        return `Authorization: Bearer ${state.authConfig.token}`;
    }
    return null;
}

The auth header is injected into the headers string before sending to extension.ts:

// src/webview/request.js
const authHeader = buildAuthHeader();
if (authHeader) {
    const hasAuthHeader = finalHeaders.toLowerCase().includes('authorization:');
    if (!hasAuthHeader) {
        finalHeaders = finalHeaders.trim() 
            ? `${finalHeaders.trim()}n${authHeader}` 
            : authHeader;
    }
}

By the time extension.ts sees the headers, they’re already encoded. The logger never sees the raw credentials.

The .vscodeignore trick that matters

Before shipping, make sure your .vscodeignore excludes node_modules:

node_modules/**
src/**
**/*.map
**/*.ts
out/test/**
out/logger.js
out/environmentTree.js
out/environmentManager.js
out/utils/**

!out/extension.js
!package.json
!README.md
!CHANGELOG.md
!LICENSE
!media/**/*

With esbuild bundling node_modules into out/extension.js, you don’t need to ship node_modules at all. My package went from 250+ files to ~15 files.

What’s next for v1.3.0

  • GraphQL support — dedicated query editor
  • Response diffing — compare two responses side by side
  • Import from Postman collections — migrate existing work
  • WebSocket support — for real-time API testing

Try it out

VS Code / Cursor:

ext install FreeRave.dotfetch

VSCodium / Open VSX:

https://open-vsx.org/extension/freerave/dotfetch

Or build from source:

git clone https://github.com/kareem2099/DotFetch.git
cd DotFetch
npm install
npm run compile
# Press F5 in VS Code to launch Extension Development Host

If you find bugs or have feature requests, open an issue on GitHub. PRs are welcome! 🚀

Built with TypeScript, esbuild, and a lot of VS Code webview debugging.

Leave a Reply