Build a Localhost Tunneling Chrome Extension: Complete Developer's Guide

39 min read

Build a Localhost Tunneling Chrome Extension: Complete Developer's Guide

Build a Localhost Tunneling Chrome Extension: Complete Developer’s Guide

Local development has always been a fundamental part of building web applications. Developers typically run their applications locally on localhost, test them in their browsers, and then deploy to production servers. However, sharing local development environments with team members, clients, or testing services has traditionally been cumbersome. This is where localhost tunneling tools like ngrok have revolutionized the development workflow.

In this comprehensive guide, we will walk you through building a complete localhost tunneling Chrome extension that enables developers to create, manage, and share secure tunnels to their local development servers directly from the browser. By the end of this tutorial, you will have a fully functional extension that can expose your local server to the internet with a single click.


Why Build a Localhost Tunneling Extension?

The ability to expose localhost to the internet is crucial for modern web development. Whether you are debugging webhooks, testing mobile responsive designs on real devices, sharing work with clients, or integrating third-party services that require publicly accessible URLs, localhost tunneling has become an essential tool in every developer’s toolkit.

Traditional approaches to sharing local development environments involve deploying to temporary staging servers or using complex VPN configurations. Localhost tunneling tools solve this problem by creating secure tunnels from your local machine to public URLs that can be shared instantly. Tools like ngrok have become industry standards, but accessing them typically requires leaving the browser and using command-line interfaces.

Building a Chrome extension that integrates these tunneling capabilities directly into the browser provides several significant advantages. You can manage tunnels without switching contexts, monitor tunnel status alongside your development workflow, and create a more streamlined experience for developers who spend most of their time in the browser.

This extension will demonstrate advanced Chrome extension concepts including service worker management, native messaging with external applications, real-time communication between extension components, and persistent storage for tunnel configurations.


Understanding the Architecture

Before diving into code, it is essential to understand how our localhost tunneling Chrome extension will work. The extension consists of several interconnected components that each serve specific purposes in the overall architecture.

The Extension Components

The manifest file defines the extension’s configuration, permissions, and components. Our extension will use Manifest V3, the current standard for Chrome extensions, which requires using service workers instead of persistent background pages.

The popup interface provides the primary user interaction point. This is the small window that appears when users click the extension icon in the Chrome toolbar. The popup will display tunnel status, controls for creating and managing tunnels, and quick access to recent tunnel URLs.

The service worker handles background operations, including communicating with the native messaging host application that manages the actual tunnel connections. The service worker coordinates between the popup, content scripts, and the native application.

The native messaging host is a separate application that runs on the user’s computer and actually manages the ngrok process or similar tunneling software. Chrome extensions cannot directly execute system processes, so we need this native component to bridge the gap.

How the System Works Together

When a user clicks the extension icon to create a new tunnel, the popup sends a message to the service worker. The service worker then communicates with the native messaging host, which starts the tunneling process locally. The native host returns the tunnel URL to the service worker, which updates the popup with the new public URL. Throughout the tunnel’s lifecycle, the service worker monitors its status and provides real-time updates to the popup interface.

This architecture ensures that tunnels persist even when the popup is closed, as the service worker continues running in the background.


Setting Up the Project Structure

Create a new directory for your extension project with the following structure:

localhost-tunnel-extension/
├── manifest.json
├── background.js
├── popup.html
├── popup.js
├── popup.css
├── options.html
├── options.js
├── nativeMessaging/
│   └── host.json
└── icons/
    ├── icon16.png
    ├── icon48.png
    └── icon128.png

This structure separates the extension’s components logically, making it easier to maintain and expand later. The nativeMessaging directory contains configuration for the native messaging host that will be registered with Chrome.


Creating the Manifest File

The manifest.json file is the heart of every Chrome extension. It tells Chrome everything about your extension, including its name, version, permissions, and which files to load.

{
  "manifest_version": 3,
  "name": "Localhost Tunnel Manager",
  "version": "1.0.0",
  "description": "Create and manage localhost tunnels directly from Chrome. Share your local development server with ngrok integration.",
  "permissions": [
    "storage",
    "nativeMessaging",
    "tabs"
  ],
  "host_permissions": [
    "<all_urls>"
  ],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "options_page": "options.html",
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

This manifest requests several important permissions. The nativeMessaging permission allows our extension to communicate with external applications on the user’s computer. The storage permission enables persisting tunnel configurations and user preferences. The tabs permission lets us interact with browser tabs, which is useful for automatically opening tunnel URLs.


Building the Service Worker

The service worker is the backbone of our extension. It runs in the background, handles messages from the popup, communicates with the native messaging host, and manages the overall tunnel lifecycle.

// background.js - Service Worker for Localhost Tunnel Manager

// Store active tunnel information
let activeTunnels = new Map();

// Initialize extension on install
chrome.runtime.onInstalled.addListener((details) => {
  if (details.reason === 'install') {
    // Set default configuration
    chrome.storage.local.set({
      defaultPort: 3000,
      defaultProtocol: 'http',
      autoOpen: true,
      tunnelHistory: []
    });
    console.log('Localhost Tunnel Manager installed successfully');
  }
});

// Handle messages from popup and other extension components
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  switch (message.action) {
    case 'createTunnel':
      createTunnel(message.port, message.protocol)
        .then(result => sendResponse(result))
        .catch(error => sendResponse({ error: error.message }));
      return true; // Keep message channel open for async response

    case 'closeTunnel':
      closeTunnel(message.tunnelId)
        .then(result => sendResponse(result))
        .catch(error => sendResponse({ error: error.message }));
      return true;

    case 'getTunnels':
      sendResponse({ tunnels: Array.from(activeTunnels.values()) });
      return false;

    case 'getStatus':
      checkTunnelStatus(message.tunnelId)
        .then(result => sendResponse(result))
        .catch(error => sendResponse({ error: error.message }));
      return true;

    default:
      sendResponse({ error: 'Unknown action' });
      return false;
  }
});

// Create a new localhost tunnel via native messaging
async function createTunnel(port, protocol = 'http') {
  try {
    // Send message to native messaging host
    const response = await chrome.runtime.sendNativeMessage(
      'application.id',
      {
        action: 'createTunnel',
        port: port,
        protocol: protocol
      }
    );

    if (response.success) {
      const tunnelInfo = {
        id: response.tunnelId,
        localPort: port,
        localProtocol: protocol,
        publicUrl: response.url,
        createdAt: Date.now(),
        status: 'active'
      };

      activeTunnels.set(response.tunnelId, tunnelInfo);
      
      // Save to history
      const history = await getTunnelHistory();
      history.unshift(tunnelInfo);
      if (history.length > 10) history.pop();
      await chrome.storage.local.set({ tunnelHistory: history });

      return { success: true, tunnel: tunnelInfo };
    } else {
      throw new Error(response.error || 'Failed to create tunnel');
    }
  } catch (error) {
    console.error('Tunnel creation error:', error);
    return { error: error.message };
  }
}

// Close an existing tunnel
async function closeTunnel(tunnelId) {
  try {
    const response = await chrome.runtime.sendNativeMessage(
      'application.id',
      {
        action: 'closeTunnel',
        tunnelId: tunnelId
      }
    );

    if (response.success) {
      activeTunnels.delete(tunnelId);
      return { success: true };
    } else {
      throw new Error(response.error || 'Failed to close tunnel');
    }
  } catch (error) {
    console.error('Tunnel closure error:', error);
    return { error: error.message };
  }
}

// Check tunnel status
async function checkTunnelStatus(tunnelId) {
  try {
    const response = await chrome.runtime.sendNativeMessage(
      'application.id',
      {
        action: 'status',
        tunnelId: tunnelId
      }
    );

    return response;
  } catch (error) {
    return { error: error.message, status: 'disconnected' };
  }
}

// Get tunnel history from storage
async function getTunnelHistory() {
  const result = await chrome.storage.local.get('tunnelHistory');
  return result.tunnelHistory || [];
}

// Handle native messaging connection errors
chrome.runtime.onConnect.addListener((port) => {
  if (port.name === 'nativeMessaging') {
    port.onDisconnect.addListener(() => {
      console.log('Native messaging host disconnected');
    });
  }
});

This service worker implements the core functionality for creating, managing, and monitoring localhost tunnels. It uses Chrome’s native messaging API to communicate with a background application that handles the actual tunneling process.


The popup provides the user interface for interacting with the extension. It should be clean, intuitive, and provide all necessary information at a glance.

<!-- popup.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Localhost Tunnel Manager</title>
  <link rel="stylesheet" href="popup.css">
</head>
<body>
  <div class="container">
    <header class="header">
      <div class="logo">
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
          <path d="M12 2L2 7l10 5 10-5-10-5z" fill="#1a73e8"/>
          <path d="M2 17l10 5 10-5" stroke="#1a73e8" stroke-width="2"/>
          <path d="M2 12l10 5 10-5" stroke="#1a73e8" stroke-width="2"/>
        </svg>
        <h1>Localhost Tunnel</h1>
      </div>
    </header>

    <main class="main">
      <section class="create-tunnel-section">
        <div class="input-group">
          <label for="protocol">Protocol</label>
          <select id="protocol">
            <option value="http">HTTP</option>
            <option value="https">HTTPS</option>
          </select>
        </div>
        <div class="input-group">
          <label for="port">Port</label>
          <input type="number" id="port" placeholder="3000" min="1" max="65535">
        </div>
        <button id="createTunnel" class="btn-primary">
          <span class="btn-icon">+</span>
          Create Tunnel
        </button>
      </section>

      <section class="tunnels-section" id="tunnelsList">
        <h2>Active Tunnels</h2>
        <div id="tunnelsContainer" class="tunnels-container">
          <p class="empty-state">No active tunnels</p>
        </div>
      </section>

      <section class="history-section">
        <h2>Recent Tunnels</h2>
        <div id="historyContainer" class="history-container">
          <!-- History items will be inserted here -->
        </div>
      </section>
    </main>

    <footer class="footer">
      <a href="options.html" class="settings-link">Settings</a>
      <span class="version">v1.0.0</span>
    </footer>
  </div>

  <script src="popup.js"></script>
</body>
</html>

The popup interface provides a clean, modern design for creating and managing tunnels. It includes input fields for specifying the port and protocol, a list of active tunnels with quick actions, and a history of recently used tunnels.


The CSS provides a professional appearance that matches Chrome’s design language while maintaining usability.

/* popup.css */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  width: 360px;
  min-height: 400px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #ffffff;
  color: #202124;
}

.container {
  display: flex;
  flex-direction: column;
  min-height: 100%;
}

.header {
  padding: 16px;
  background: linear-gradient(135deg, #1a73e8 0%, #0d47a1 100%);
  color: white;
}

.logo {
  display: flex;
  align-items: center;
  gap: 10px;
}

.logo h1 {
  font-size: 18px;
  font-weight: 600;
}

.main {
  flex: 1;
  padding: 16px;
}

.create-tunnel-section {
  background: #f8f9fa;
  border-radius: 8px;
  padding: 16px;
  margin-bottom: 16px;
}

.input-group {
  margin-bottom: 12px;
}

.input-group label {
  display: block;
  font-size: 12px;
  font-weight: 500;
  color: #5f6368;
  margin-bottom: 4px;
}

.input-group input,
.input-group select {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #dadce0;
  border-radius: 4px;
  font-size: 14px;
  outline: none;
  transition: border-color 0.2s;
}

.input-group input:focus,
.input-group select:focus {
  border-color: #1a73e8;
}

.btn-primary {
  width: 100%;
  padding: 10px 16px;
  background: #1a73e8;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  transition: background-color 0.2s;
}

.btn-primary:hover {
  background: #1557b0;
}

.btn-primary:disabled {
  background: #9fc3f4;
  cursor: not-allowed;
}

.btn-icon {
  font-size: 18px;
  font-weight: 300;
}

.tunnels-section,
.history-section {
  margin-bottom: 16px;
}

.tunnels-section h2,
.history-section h2 {
  font-size: 14px;
  font-weight: 600;
  color: #5f6368;
  margin-bottom: 8px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

.tunnel-card {
  background: white;
  border: 1px solid #dadce0;
  border-radius: 8px;
  padding: 12px;
  margin-bottom: 8px;
}

.tunnel-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.tunnel-status {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  font-weight: 500;
}

.status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
}

.status-active {
  background: #34a853;
}

.status-inactive {
  background: #fbbc04;
}

.tunnel-url {
  display: block;
  padding: 8px;
  background: #f1f3f4;
  border-radius: 4px;
  font-family: 'Monaco', 'Menlo', monospace;
  font-size: 12px;
  color: #1a73e8;
  text-decoration: none;
  word-break: break-all;
  margin-bottom: 8px;
}

.tunnel-url:hover {
  background: #e8f0fe;
}

.tunnel-info {
  font-size: 12px;
  color: #5f6368;
  margin-bottom: 8px;
}

.tunnel-actions {
  display: flex;
  gap: 8px;
}

.btn-secondary {
  flex: 1;
  padding: 6px 12px;
  background: white;
  border: 1px solid #dadce0;
  border-radius: 4px;
  font-size: 12px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.btn-secondary:hover {
  background: #f1f3f4;
}

.btn-danger {
  color: #d93025;
  border-color: #d93025;
}

.btn-danger:hover {
  background: #fce8e6;
}

.empty-state {
  text-align: center;
  padding: 24px;
  color: #5f6368;
  font-size: 14px;
}

.footer {
  padding: 12px 16px;
  border-top: 1px solid #dadce0;
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 12px;
  color: #5f6368;
}

.settings-link {
  color: #1a73e8;
  text-decoration: none;
}

.settings-link:hover {
  text-decoration: underline;
}

This styling creates a clean, professional interface that feels native to the Chrome browser. The design uses a consistent color palette, clear visual hierarchy, and appropriate spacing for touch targets.


The popup JavaScript handles user interactions and communicates with the service worker.

// popup.js

document.addEventListener('DOMContentLoaded', async () => {
  // Load saved preferences
  const prefs = await chrome.storage.local.get(['defaultPort', 'defaultProtocol']);
  
  if (prefs.defaultPort) {
    document.getElementById('port').value = prefs.defaultPort;
  }
  if (prefs.defaultProtocol) {
    document.getElementById('protocol').value = prefs.defaultProtocol;
  }

  // Set up event listeners
  document.getElementById('createTunnel').addEventListener('click', createTunnel);
  
  // Load initial data
  loadTunnels();
  loadHistory();
});

// Create a new tunnel
async function createTunnel() {
  const port = parseInt(document.getElementById('port').value);
  const protocol = document.getElementById('protocol').value;
  
  if (!port || port < 1 || port > 65535) {
    showError('Please enter a valid port number (1-65535)');
    return;
  }

  const button = document.getElementById('createTunnel');
  button.disabled = true;
  button.textContent = 'Creating...';

  try {
    const response = await chrome.runtime.sendMessage({
      action: 'createTunnel',
      port: port,
      protocol: protocol
    });

    if (response.error) {
      showError(response.error);
    } else if (response.success) {
      showSuccess('Tunnel created successfully!');
      document.getElementById('port').value = '';
      loadTunnels();
      loadHistory();
    }
  } catch (error) {
    showError('Failed to create tunnel: ' + error.message);
  } finally {
    button.disabled = false;
    button.innerHTML = '<span class="btn-icon">+</span> Create Tunnel';
  }
}

// Load active tunnels
async function loadTunnels() {
  const container = document.getElementById('tunnelsContainer');
  
  try {
    const response = await chrome.runtime.sendMessage({ action: 'getTunnels' });
    const tunnels = response.tunnels || [];

    if (tunnels.length === 0) {
      container.innerHTML = '<p class="empty-state">No active tunnels</p>';
      return;
    }

    container.innerHTML = tunnels.map(tunnel => createTunnelCard(tunnel)).join('');
    
    // Add event listeners to buttons
    tunnels.forEach(tunnel => {
      document.getElementById(`open-${tunnel.id}`)?.addEventListener('click', () => {
        chrome.tabs.create({ url: tunnel.publicUrl });
      });
      
      document.getElementById(`copy-${tunnel.id}`)?.addEventListener('click', () => {
        navigator.clipboard.writeText(tunnel.publicUrl);
        showSuccess('URL copied to clipboard!');
      });
      
      document.getElementById(`close-${tunnel.id}`)?.addEventListener('click', () => {
        closeTunnel(tunnel.id);
      });
    });
  } catch (error) {
    container.innerHTML = '<p class="empty-state">Error loading tunnels</p>';
  }
}

// Close a tunnel
async function closeTunnel(tunnelId) {
  try {
    const response = await chrome.runtime.sendMessage({
      action: 'closeTunnel',
      tunnelId: tunnelId
    });

    if (response.success) {
      showSuccess('Tunnel closed');
      loadTunnels();
    } else if (response.error) {
      showError(response.error);
    }
  } catch (error) {
    showError('Failed to close tunnel: ' + error.message);
  }
}

// Load tunnel history
async function loadHistory() {
  const container = document.getElementById('historyContainer');
  const result = await chrome.storage.local.get('tunnelHistory');
  const history = result.tunnelHistory || [];

  if (history.length === 0) {
    container.innerHTML = '<p class="empty-state">No recent tunnels</p>';
    return;
  }

  container.innerHTML = history.slice(0, 5).map(tunnel => `
    <div class="tunnel-card">
      <div class="tunnel-info">
        ${tunnel.localProtocol}://localhost:${tunnel.localPort}
      </div>
      <a href="#" class="tunnel-url" data-url="${tunnel.publicUrl}">
        ${tunnel.publicUrl}
      </a>
    </div>
  `).join('');

  // Add click handlers for history URLs
  container.querySelectorAll('.tunnel-url').forEach(link => {
    link.addEventListener('click', (e) => {
      e.preventDefault();
      chrome.tabs.create({ url: link.dataset.url });
    });
  });
}

// Create HTML for tunnel card
function createTunnelCard(tunnel) {
  return `
    <div class="tunnel-card">
      <div class="tunnel-header">
        <div class="tunnel-status">
          <span class="status-dot status-${tunnel.status}"></span>
          <span>${tunnel.status === 'active' ? 'Active' : 'Inactive'}</span>
        </div>
      </div>
      <div class="tunnel-info">
        ${tunnel.localProtocol}://localhost:${tunnel.localPort}
      </div>
      <a href="#" class="tunnel-url" id="open-${tunnel.id}" data-url="${tunnel.publicUrl}">
        ${tunnel.publicUrl}
      </a>
      <div class="tunnel-actions">
        <button class="btn-secondary" id="copy-${tunnel.id}">Copy URL</button>
        <button class="btn-secondary btn-danger" id="close-${tunnel.id}">Close</button>
      </div>
    </div>
  `;
}

// Show error message
function showError(message) {
  // Simple alert for now - could be enhanced with toast notifications
  alert('Error: ' + message);
}

// Show success message
function showSuccess(message) {
  // Could implement toast notifications
  console.log('Success:', message);
}

This popup script handles all user interactions, from creating new tunnels to managing existing ones. It communicates with the service worker to perform actual tunnel operations and updates the UI based on the results.


The Native Messaging Host

Chrome extensions cannot directly run system processes, so we need a native messaging host application to manage the tunneling software. This is a separate program that runs on the user’s computer and communicates with the extension.

For a production extension, you would create a native messaging host application. Here is how the communication works:

Registering the Native Messaging Host

You need to register your native messaging host with Chrome by creating a JSON manifest file. This file tells Chrome how to find and launch your application.

// nativeMessaging/host.json (on Windows, place in registry or app directory)
{
  "name": "localhost_tunnel_manager",
  "description": "Native messaging host for Localhost Tunnel Manager extension",
  "path": "C:\\Program Files\\LocalhostTunnelManager\\host.exe",
  "type": "stdio",
  "allowed_origins": [
    "chrome-extension://YOUR_EXTENSION_ID/*"
  ]
}

Native Messaging Host Implementation

The native messaging host would be a simple application that manages the tunneling process. Here is a conceptual implementation in Python:

# native_messaging_host.py (example)
import json
import subprocess
import sys
import os
import time

# Store active tunnel processes
active_tunnels = {}

def create_tunnel(port, protocol):
    """Create a new ngrok tunnel."""
    try:
        # Start ngrok process
        process = subprocess.Popen(
            ['ngrok', protocol, str(port)],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE
        )
        
        # Wait for ngrok to start and get the URL
        time.sleep(2)
        
        # In production, you would parse ngrok's API to get the actual URL
        # For this example, we'll use a placeholder
        tunnel_id = f"tunnel_{port}_{int(time.time())}"
        public_url = f"https://{tunnel_id}.ngrok.io"
        
        active_tunnels[tunnel_id] = {
            'process': process,
            'port': port,
            'protocol': protocol,
            'url': public_url
        }
        
        return {
            'success': True,
            'tunnelId': tunnel_id,
            'url': public_url
        }
    except Exception as e:
        return {
            'success': False,
            'error': str(e)
        }

def close_tunnel(tunnel_id):
    """Close an existing tunnel."""
    if tunnel_id in active_tunnels:
        try:
            tunnel = active_tunnels[tunnel_id]
            tunnel['process'].terminate()
            tunnel['process'].wait()
            del active_tunnels[tunnel_id]
            return {'success': True}
        except Exception as e:
            return {'success': False, 'error': str(e)}
    return {'success': False, 'error': 'Tunnel not found'}

def get_status(tunnel_id):
    """Get tunnel status."""
    if tunnel_id in active_tunnels:
        tunnel = active_tunnels[tunnel_id]
        return {
            'success': True,
            'status': 'active',
            'url': tunnel['url']
        }
    return {'success': False, 'status': 'inactive'}

def main():
    """Main entry point for native messaging host."""
    while True:
        try:
            # Read message from Chrome
            line = sys.stdin.readline()
            if not line:
                break
                
            message = json.loads(line)
            action = message.get('action')
            
            # Handle different actions
            if action == 'createTunnel':
                result = create_tunnel(
                    message.get('port'),
                    message.get('protocol', 'http')
                )
            elif action == 'closeTunnel':
                result = close_tunnel(message.get('tunnelId'))
            elif action == 'status':
                result = get_status(message.get('tunnelId'))
            else:
                result = {'success': False, 'error': 'Unknown action'}
            
            # Send response to Chrome
            sys.stdout.write(json.dumps(result) + '\n')
            sys.stdout.flush()
            
        except Exception as e:
            error_response = {'success': False, 'error': str(e)}
            sys.stdout.write(json.dumps(error_response) + '\n')
            sys.stdout.flush()

if __name__ == '__main__':
    main()

This native messaging host provides the bridge between your Chrome extension and the actual tunneling software running on the user’s computer.


Building the Options Page

The options page allows users to configure the extension’s behavior according to their preferences.

<!-- options.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Localhost Tunnel Manager - Settings</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      max-width: 600px;
      margin: 0 auto;
      padding: 20px;
      background: #f8f9fa;
    }
    h1 {
      color: #202124;
      margin-bottom: 24px;
    }
    .settings-section {
      background: white;
      border-radius: 8px;
      padding: 20px;
      margin-bottom: 16px;
      box-shadow: 0 1px 3px rgba(0,0,0,0.1);
    }
    .settings-section h2 {
      font-size: 16px;
      color: #5f6368;
      margin-bottom: 16px;
    }
    .setting-item {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 12px 0;
      border-bottom: 1px solid #f1f3f4;
    }
    .setting-item:last-child {
      border-bottom: none;
    }
    .setting-label {
      font-size: 14px;
      color: #202124;
    }
    .setting-description {
      font-size: 12px;
      color: #5f6368;
      margin-top: 4px;
    }
    input[type="number"],
    select {
      padding: 8px 12px;
      border: 1px solid #dadce0;
      border-radius: 4px;
      font-size: 14px;
      width: 120px;
    }
    input[type="checkbox"] {
      width: 18px;
      height: 18px;
      cursor: pointer;
    }
    .save-button {
      background: #1a73e8;
      color: white;
      border: none;
      padding: 12px 24px;
      border-radius: 4px;
      font-size: 14px;
      cursor: pointer;
      margin-top: 20px;
    }
    .save-button:hover {
      background: #1557b0;
    }
    .status-message {
      margin-top: 12px;
      padding: 12px;
      border-radius: 4px;
      display: none;
    }
    .status-success {
      background: #e6f4ea;
      color: #1e8e3e;
    }
    .status-error {
      background: #fce8e6;
      color: #d93025;
    }
  </style>
</head>
<body>
  <h1>Settings</h1>
  
  <div class="settings-section">
    <h2>Default Tunnel Settings</h2>
    <div class="setting-item">
      <div>
        <div class="setting-label">Default Port</div>
        <div class="setting-description">The default port to use when creating tunnels</div>
      </div>
      <input type="number" id="defaultPort" min="1" max="65535" value="3000">
    </div>
    <div class="setting-item">
      <div>
        <div class="setting-label">Default Protocol</div>
        <div class="setting-description">The default protocol for new tunnels</div>
      </div>
      <select id="defaultProtocol">
        <option value="http">HTTP</option>
        <option value="https">HTTPS</option>
      </select>
    </div>
    <div class="setting-item">
      <div>
        <div class="setting-label">Auto-open tunnel URL</div>
        <div class="setting-description">Automatically open tunnel URL in a new tab when created</div>
      </div>
      <input type="checkbox" id="autoOpen">
    </div>
  </div>

  <div class="settings-section">
    <h2>ngrok Configuration</h2>
    <div class="setting-item">
      <div>
        <div class="setting-label">ngrok Auth Token</div>
        <div class="setting-description">Your ngrok authentication token (optional)</div>
      </div>
      <input type="password" id="ngrokToken" placeholder="Enter your ngrok token">
    </div>
  </div>

  <button class="save-button" id="saveSettings">Save Settings</button>
  <div id="statusMessage" class="status-message"></div>

  <script src="options.js"></script>
</body>
</html>
// options.js
document.addEventListener('DOMContentLoaded', async () => {
  // Load existing settings
  const settings = await chrome.storage.local.get([
    'defaultPort',
    'defaultProtocol',
    'autoOpen',
    'ngrokToken'
  ]);

  document.getElementById('defaultPort').value = settings.defaultPort || 3000;
  document.getElementById('defaultProtocol').value = settings.defaultProtocol || 'http';
  document.getElementById('autoOpen').checked = settings.autoOpen || false;
  document.getElementById('ngrokToken').value = settings.ngrokToken || '';

  // Save settings
  document.getElementById('saveSettings').addEventListener('click', async () => {
    const newSettings = {
      defaultPort: parseInt(document.getElementById('defaultPort').value),
      defaultProtocol: document.getElementById('defaultProtocol').value,
      autoOpen: document.getElementById('autoOpen').checked,
      ngrokToken: document.getElementById('ngrokToken').value
    };

    await chrome.storage.local.set(newSettings);
    
    showStatus('Settings saved successfully!', 'success');
  });
});

function showStatus(message, type) {
  const statusEl = document.getElementById('statusMessage');
  statusEl.textContent = message;
  statusEl.className = 'status-message status-' + type;
  statusEl.style.display = 'block';
  
  setTimeout(() => {
    statusEl.style.display = 'none';
  }, 3000);
}

Loading and Testing the Extension

Now that we have built all the components, let us load the extension into Chrome and test it.

Loading the Extension

  1. Open Chrome and navigate to chrome://extensions/
  2. Enable “Developer mode” using the toggle in the top-right corner
  3. Click “Load unpacked” and select your extension’s root directory
  4. Your extension will appear in the extensions list and in the toolbar

Testing the Extension

Once loaded, click the extension icon in the Chrome toolbar. You should see the popup interface with options to create a new tunnel. Enter a port number (for example, 3000 for a typical development server) and click “Create Tunnel.”

The extension will communicate with the native messaging host to create the tunnel. The popup should display the public URL that can be shared with others.

Debugging Tips

If you encounter issues during development, here are some debugging strategies:

First, check the service worker console logs. Navigate to chrome://extensions/, find your extension, and click “service worker” to open the DevTools for the service worker.

Second, verify that the native messaging host is properly registered. On Windows, you need to add the host manifest to the registry or place it in a specific location. On macOS and Linux, you place it in a specific Chrome directory.

Third, check for permission errors. The extension needs the nativeMessaging permission to communicate with external applications.


Security Considerations

When building an extension that manages network tunnels, security is paramount. Here are important security considerations:

Native Messaging Security

Native messaging provides powerful capabilities but also introduces security risks. Always validate all data exchanged between your extension and the native messaging host. Never blindly trust messages from the native application.

Token Storage

If users provide ngrok auth tokens or other sensitive credentials, store them securely using Chrome’s storage API with appropriate encryption. Never store tokens in plain text.

URL Validation

Always validate tunnel URLs before displaying them or allowing users to open them. Malicious tunnels could redirect users to phishing sites.

Principle of Least Privilege

Only request the permissions your extension absolutely needs. For this extension, we need nativeMessaging for tunnel communication, storage for persisting settings, and tabs for opening tunnel URLs.


Conclusion

You have now built a complete localhost tunneling Chrome extension that enables developers to create and manage secure tunnels to their local development servers directly from the browser. The extension demonstrates several advanced Chrome extension concepts, including service workers for background processing, native messaging for system integration, real-time communication between extension components, and persistent storage for user preferences.

This extension can be extended in numerous ways. You could add support for multiple tunneling providers beyond ngrok, implement real-time tunnel monitoring with WebSocket connections, add team collaboration features for sharing tunnels with team members, or integrate with CI/CD pipelines for automated testing environments.

The localhost tunnel extension you have built serves as a solid foundation for any developer productivity tool that needs to bridge the gap between the browser and local development environments. With Chrome’s powerful extension APIs and the flexibility of native messaging, the possibilities for expansion are virtually unlimited.


Next Steps

Now that you have a working localhost tunneling extension, here are some suggested improvements and expansions:

First, implement full ngrok API integration to get real-time tunnel status, request inspection, and replay capabilities directly in the extension popup.

Second, add support for custom subdomains and tunnel configurations to give users more control over their tunnel URLs.

Third, implement a tunnel dashboard that shows request logs, response times, and other analytics directly within Chrome.

Fourth, consider adding team features that allow sharing tunnels with team members without requiring them to run their own tunnel clients.

Fifth, explore integrating with other development tools like VS Code to provide a seamless development experience across your entire workflow.

The Chrome extension ecosystem provides endless opportunities for building tools that enhance developer productivity. This localhost tunneling extension is just one example of how you can bring powerful server-side capabilities directly into the browser.


This guide is part of the Chrome Extension Guide by theluckystrike — your comprehensive resource for Chrome extension development.

No previous article
No next article