DEV Community

Cover image for I Built a Full HTTP Client Extension for VS Code — Here's Everything I Learned
freerave
freerave

Posted on

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
Enter fullscreen mode Exit fullscreen mode

🎥 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);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: '' }
};
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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>`;
        }
    );
}
Enter fullscreen mode Exit fullscreen mode

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 */
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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/**/*
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

VSCodium / Open VSX:

https://open-vsx.org/extension/freerave/dotfetch
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)