Chrome Extension Iframe Communication — Best Practices

38 min read

iframe Communication Patterns in Chrome Extensions

iframes are ubiquitous on the web, and Chrome extensions frequently need to communicate with them, embed them, or use them as sandboxed execution environments. This guide covers eight patterns for working with iframes in Manifest V3 extensions, from basic message passing to advanced UI injection techniques.

Related guides: Content Script Isolation Web Accessible Resources

Pattern 1: Content Script to Page iframe Communication

Content scripts can access iframes on the host page, but cross-origin restrictions apply. For same-origin iframes, you can inject directly. For cross-origin iframes, use window.postMessage.

// content-script.ts
function sendToSameOriginIframe(
  iframe: HTMLIFrameElement,
  message: unknown
): void {
  // Same-origin: direct access to contentWindow
  const iframeWindow = iframe.contentWindow;
  if (!iframeWindow) {
    console.warn("iframe contentWindow not accessible");
    return;
  }

  // Post message with the page's origin for same-origin frames
  iframeWindow.postMessage(
    { source: "my-extension", payload: message },
    window.location.origin
  );
}

function sendToCrossOriginIframe(
  iframe: HTMLIFrameElement,
  message: unknown,
  targetOrigin: string
): void {
  const iframeWindow = iframe.contentWindow;
  if (!iframeWindow) return;

  // Always specify the exact target origin, never use "*"
  iframeWindow.postMessage(
    { source: "my-extension", payload: message },
    targetOrigin
  );
}

// Listen for responses from iframes
window.addEventListener("message", (event: MessageEvent) => {
  // Validate origin before processing
  if (!isAllowedOrigin(event.origin)) return;
  if (event.data?.source !== "my-extension-iframe") return;

  console.log("Response from iframe:", event.data.payload);
});

function isAllowedOrigin(origin: string): boolean {
  const allowed = ["https://trusted-site.example.com"];
  return allowed.includes(origin);
}

// Find and communicate with all iframes on the page
function broadcastToIframes(message: unknown): void {
  const iframes = document.querySelectorAll("iframe");
  iframes.forEach((iframe) => {
    try {
      iframe.contentWindow?.postMessage(
        { source: "my-extension", payload: message },
        "*" // Only use "*" for broadcast when message contains no secrets
      );
    } catch (err) {
      // Security error for sandboxed frames without allow-same-origin
    }
  });
}

Gotchas:


Pattern 2: Extension iframe in Content Script (Shadow DOM)

Inject an extension-hosted iframe into a page using Shadow DOM to isolate styles and prevent the host page from interfering with your UI.

// content-script.ts
function injectExtensionUI(): void {
  // Create a host element
  const host = document.createElement("div");
  host.id = "my-extension-root";

  // Attach a closed shadow root so the page cannot access it
  const shadow = host.attachShadow({ mode: "closed" });

  // Style the container
  const style = document.createElement("style");
  style.textContent = `
    :host {
      all: initial;
      position: fixed;
      top: 16px;
      right: 16px;
      z-index: 2147483647;
      width: 380px;
      height: 500px;
      border: none;
      border-radius: 8px;
      box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
      overflow: hidden;
    }
    iframe {
      width: 100%;
      height: 100%;
      border: none;
    }
  `;

  // Create the iframe pointing to an extension page
  const iframe = document.createElement("iframe");
  iframe.src = chrome.runtime.getURL("panel.html");
  iframe.setAttribute("allow", "");

  shadow.appendChild(style);
  shadow.appendChild(iframe);
  document.body.appendChild(host);

  // Set up communication between the content script and the iframe
  setupIframeBridge(iframe);
}

function setupIframeBridge(iframe: HTMLIFrameElement): void {
  // Listen for messages from the extension iframe
  window.addEventListener("message", (event: MessageEvent) => {
    // Only accept messages from our extension origin
    if (event.source !== iframe.contentWindow) return;

    const { action, data } = event.data;
    switch (action) {
      case "get-page-info":
        iframe.contentWindow?.postMessage(
          {
            action: "page-info",
            data: {
              title: document.title,
              url: window.location.href,
              selection: window.getSelection()?.toString() || "",
            },
          },
          chrome.runtime.getURL("")
        );
        break;

      case "close-panel":
        document.getElementById("my-extension-root")?.remove();
        break;
    }
  });
}

// Initialize when the content script loads
injectExtensionUI();
// panel.ts (loaded inside the extension iframe)
window.addEventListener("DOMContentLoaded", () => {
  // Request page info from the content script
  window.parent.postMessage({ action: "get-page-info" }, "*");

  window.addEventListener("message", (event: MessageEvent) => {
    if (event.data.action === "page-info") {
      const info = event.data.data;
      document.getElementById("page-title")!.textContent = info.title;
    }
  });
});

Gotchas:


Pattern 3: Cross-Origin iframe Messaging with postMessage

When communicating across origins, a structured protocol with handshake, validation, and typed messages prevents security issues and race conditions.

// types/iframe-protocol.ts
interface HandshakeMessage {
  type: "handshake";
  version: number;
  capabilities: string[];
}

interface DataMessage {
  type: "data";
  channel: string;
  payload: unknown;
  requestId?: string;
}

interface AckMessage {
  type: "ack";
  requestId: string;
  status: "ok" | "error";
  error?: string;
}

type ProtocolMessage = HandshakeMessage | DataMessage | AckMessage;

const PROTOCOL_KEY = "__ext_iframe_protocol__";
// iframe-bridge.ts
class IframeBridge {
  private targetWindow: Window;
  private targetOrigin: string;
  private connected = false;
  private pendingRequests = new Map<
    string,
    { resolve: (v: unknown) => void; reject: (e: Error) => void }
  >();
  private handlers = new Map<string, (payload: unknown) => unknown>();

  constructor(targetWindow: Window, targetOrigin: string) {
    this.targetWindow = targetWindow;
    this.targetOrigin = targetOrigin;

    window.addEventListener("message", this.onMessage.bind(this));
  }

  private onMessage(event: MessageEvent): void {
    // Strict origin check
    if (event.origin !== this.targetOrigin) return;
    if (event.source !== this.targetWindow) return;

    const data = event.data;
    if (!data || data[PROTOCOL_KEY] !== true) return;

    const message: ProtocolMessage = data.message;

    switch (message.type) {
      case "handshake":
        this.connected = true;
        this.send({
          type: "ack",
          requestId: "handshake",
          status: "ok",
        });
        break;

      case "data":
        this.handleDataMessage(message);
        break;

      case "ack":
        this.handleAck(message);
        break;
    }
  }

  private handleDataMessage(message: DataMessage): void {
    const handler = this.handlers.get(message.channel);
    if (!handler) return;

    try {
      const result = handler(message.payload);
      if (message.requestId) {
        this.send({
          type: "ack",
          requestId: message.requestId,
          status: "ok",
        });
      }
    } catch (err) {
      if (message.requestId) {
        this.send({
          type: "ack",
          requestId: message.requestId,
          status: "error",
          error: err instanceof Error ? err.message : String(err),
        });
      }
    }
  }

  private handleAck(message: AckMessage): void {
    const pending = this.pendingRequests.get(message.requestId);
    if (!pending) return;
    this.pendingRequests.delete(message.requestId);

    if (message.status === "ok") {
      pending.resolve(undefined);
    } else {
      pending.reject(new Error(message.error || "Unknown error"));
    }
  }

  private send(message: ProtocolMessage): void {
    this.targetWindow.postMessage(
      { [PROTOCOL_KEY]: true, message },
      this.targetOrigin
    );
  }

  on(channel: string, handler: (payload: unknown) => unknown): void {
    this.handlers.set(channel, handler);
  }

  async request(channel: string, payload: unknown): Promise<void> {
    const requestId = crypto.randomUUID();
    return new Promise((resolve, reject) => {
      this.pendingRequests.set(requestId, { resolve: resolve as any, reject });
      this.send({ type: "data", channel, payload, requestId });

      setTimeout(() => {
        if (this.pendingRequests.has(requestId)) {
          this.pendingRequests.delete(requestId);
          reject(new Error("Request timed out"));
        }
      }, 5000);
    });
  }

  initiateHandshake(): void {
    this.send({
      type: "handshake",
      version: 1,
      capabilities: ["data", "ack"],
    });
  }

  destroy(): void {
    window.removeEventListener("message", this.onMessage.bind(this));
    this.pendingRequests.clear();
    this.handlers.clear();
  }
}

Gotchas:


Pattern 4: Sandboxed iframe for Untrusted Content

Chrome extensions can use sandboxed pages to run untrusted code (such as user-provided templates or third-party scripts) without access to extension APIs.

// manifest.json (partial)
{
  "sandbox": {
    "pages": ["sandbox.html"]
  }
}
<!-- sandbox.html -->
<!DOCTYPE html>
<html>
<head><title>Sandbox</title></head>
<body>
  <script src="sandbox.js"></script>
</body>
</html>
// sandbox.ts -- runs in a sandboxed, null-origin context
// No access to chrome.* APIs here

window.addEventListener("message", (event: MessageEvent) => {
  const { action, template, data } = event.data;

  if (action === "render-template") {
    try {
      // Safe to eval user templates here -- sandboxed context
      const renderFn = new Function("data", `return \`${template}\`;`);
      const result = renderFn(data);
      event.source?.postMessage(
        { action: "render-result", result },
        event.origin as any
      );
    } catch (err) {
      event.source?.postMessage(
        {
          action: "render-error",
          error: err instanceof Error ? err.message : String(err),
        },
        event.origin as any
      );
    }
  }
});
// popup.ts or options.ts -- the extension page hosting the sandbox
function createSandbox(): HTMLIFrameElement {
  const iframe = document.createElement("iframe");
  iframe.src = "sandbox.html";
  iframe.style.display = "none";
  document.body.appendChild(iframe);
  return iframe;
}

async function renderTemplate(
  sandbox: HTMLIFrameElement,
  template: string,
  data: Record<string, unknown>
): Promise<string> {
  return new Promise((resolve, reject) => {
    const handler = (event: MessageEvent) => {
      if (event.source !== sandbox.contentWindow) return;

      window.removeEventListener("message", handler);

      if (event.data.action === "render-result") {
        resolve(event.data.result);
      } else if (event.data.action === "render-error") {
        reject(new Error(event.data.error));
      }
    };

    window.addEventListener("message", handler);

    sandbox.contentWindow?.postMessage(
      { action: "render-template", template, data },
      "*" // Sandboxed pages have null origin, must use "*"
    );
  });
}

// Usage
const sandbox = createSandbox();
const html = await renderTemplate(
  sandbox,
  "<h1>${data.title}</h1><p>${data.body}</p>",
  { title: "Hello", body: "World" }
);

Gotchas:


Pattern 5: iframe Permission and CSP Considerations

Chrome extensions enforce a Content Security Policy that affects which iframes can be embedded and what they can do. Understanding these constraints prevents silent failures.

// manifest.json -- CSP configuration
{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' https://trusted-embed.example.com",
    "sandbox": "sandbox allow-scripts; script-src 'self' 'unsafe-eval'; object-src 'self'"
  }
}
// csp-safe-iframe-loader.ts
interface IframeConfig {
  url: string;
  container: HTMLElement;
  sandbox?: string[];
  allow?: string[];
  onLoad?: () => void;
  onError?: (error: string) => void;
}

function createSecureIframe(config: IframeConfig): HTMLIFrameElement {
  const iframe = document.createElement("iframe");

  // Set sandbox attributes for defense in depth
  const sandboxFlags = config.sandbox || [
    "allow-scripts",
    "allow-same-origin", // Required for postMessage origin checking
  ];
  iframe.setAttribute("sandbox", sandboxFlags.join(" "));

  // Permissions policy (formerly feature policy)
  const allowFlags = config.allow || [
    "clipboard-read",
    "clipboard-write",
  ];
  iframe.setAttribute("allow", allowFlags.join("; "));

  // Prevent the iframe from navigating the top window
  iframe.setAttribute("referrerpolicy", "no-referrer");

  // CSP via meta tag is not reliable for iframes. Use headers
  // or the sandbox attribute instead.

  iframe.addEventListener("load", () => {
    config.onLoad?.();
  });

  iframe.addEventListener("error", () => {
    config.onError?.("Failed to load iframe");
  });

  iframe.src = config.url;
  config.container.appendChild(iframe);

  return iframe;
}

// Validate that an iframe URL is allowed before loading
function isUrlAllowed(url: string, allowedPatterns: string[]): boolean {
  try {
    const parsed = new URL(url);
    return allowedPatterns.some((pattern) => {
      if (pattern.startsWith("*.")) {
        const domain = pattern.slice(2);
        return (
          parsed.hostname === domain ||
          parsed.hostname.endsWith("." + domain)
        );
      }
      return parsed.origin === pattern;
    });
  } catch {
    return false;
  }
}
// content-script.ts -- monitoring iframe CSP violations
document.addEventListener("securitypolicyviolation", (event) => {
  if (event.violatedDirective === "frame-src") {
    console.warn(
      `[Extension] Blocked iframe load: ${event.blockedURI} ` +
      `(violated ${event.violatedDirective})`
    );
  }
});

Gotchas:


Pattern 6: Detecting and Interacting with Page iframes

Content scripts may need to find, filter, and interact with iframes already present on the host page. This requires careful DOM traversal and timing.

// iframe-detector.ts
interface DetectedIframe {
  element: HTMLIFrameElement;
  src: string;
  origin: string | null;
  isCrossOrigin: boolean;
  isVisible: boolean;
}

function detectIframes(): DetectedIframe[] {
  const iframes = Array.from(document.querySelectorAll("iframe"));

  return iframes.map((iframe) => {
    let origin: string | null = null;
    let isCrossOrigin = true;

    try {
      // Accessing contentDocument throws for cross-origin iframes
      const doc = iframe.contentDocument;
      if (doc) {
        origin = new URL(iframe.src || window.location.href).origin;
        isCrossOrigin = false;
      }
    } catch {
      try {
        origin = new URL(iframe.src).origin;
      } catch {
        origin = null;
      }
    }

    const rect = iframe.getBoundingClientRect();
    const isVisible = rect.width > 0 && rect.height > 0 &&
      window.getComputedStyle(iframe).display !== "none";

    return {
      element: iframe,
      src: iframe.src || "(about:blank)",
      origin,
      isCrossOrigin,
      isVisible,
    };
  });
}

// Watch for dynamically added iframes
function observeNewIframes(
  callback: (iframe: HTMLIFrameElement) => void
): MutationObserver {
  const observer = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      for (const node of mutation.addedNodes) {
        if (node instanceof HTMLIFrameElement) {
          callback(node);
        }
        // Check descendants of added nodes
        if (node instanceof HTMLElement) {
          const nested = node.querySelectorAll("iframe");
          nested.forEach((iframe) => callback(iframe));
        }
      }
    }
  });

  observer.observe(document.documentElement, {
    childList: true,
    subtree: true,
  });

  return observer;
}

// Inject a content script into same-origin iframes
function injectIntoSameOriginIframes(scriptFn: () => void): void {
  const iframes = detectIframes().filter((f) => !f.isCrossOrigin);

  for (const { element } of iframes) {
    try {
      const doc = element.contentDocument;
      if (!doc) continue;

      const script = doc.createElement("script");
      script.textContent = `(${scriptFn.toString()})()`;
      doc.head.appendChild(script);
      doc.head.removeChild(script);
    } catch (err) {
      console.warn("Failed to inject into iframe:", err);
    }
  }
}

// Wait for an iframe to load before interacting
function waitForIframeLoad(
  iframe: HTMLIFrameElement,
  timeoutMs = 10000
): Promise<void> {
  return new Promise((resolve, reject) => {
    if (iframe.contentDocument?.readyState === "complete") {
      resolve();
      return;
    }

    const timer = setTimeout(() => {
      reject(new Error("iframe load timeout"));
    }, timeoutMs);

    iframe.addEventListener(
      "load",
      () => {
        clearTimeout(timer);
        resolve();
      },
      { once: true }
    );
  });
}

Gotchas:


Pattern 7: Extension Popup with Embedded iframes

Extension popups can embed iframes to load external dashboards, previews, or dynamically generated content. This pattern requires careful CSP and sizing management.

// popup.ts
interface EmbedConfig {
  url: string;
  minHeight: number;
  maxHeight: number;
}

class PopupEmbedManager {
  private iframe: HTMLIFrameElement | null = null;
  private container: HTMLElement;

  constructor(containerId: string) {
    const el = document.getElementById(containerId);
    if (!el) throw new Error(`Container ${containerId} not found`);
    this.container = el;
  }

  embed(config: EmbedConfig): void {
    // Remove existing iframe if any
    this.iframe?.remove();

    this.iframe = document.createElement("iframe");
    this.iframe.src = config.url;
    this.iframe.style.width = "100%";
    this.iframe.style.height = `${config.minHeight}px`;
    this.iframe.style.border = "none";
    this.iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");

    // Listen for resize requests from the embedded page
    window.addEventListener("message", (event: MessageEvent) => {
      if (event.source !== this.iframe?.contentWindow) return;

      if (event.data.type === "resize") {
        const height = Math.min(
          Math.max(event.data.height, config.minHeight),
          config.maxHeight
        );
        if (this.iframe) {
          this.iframe.style.height = `${height}px`;
        }
        // Resize the popup itself
        document.body.style.height = `${height + 50}px`;
      }
    });

    this.iframe.addEventListener("load", () => {
      // Send configuration to the embedded page
      this.iframe?.contentWindow?.postMessage(
        { type: "init", theme: "dark" },
        new URL(config.url).origin
      );
    });

    this.container.appendChild(this.iframe);
  }

  destroy(): void {
    this.iframe?.remove();
    this.iframe = null;
  }
}

// Usage in popup
document.addEventListener("DOMContentLoaded", () => {
  const manager = new PopupEmbedManager("embed-container");

  manager.embed({
    url: chrome.runtime.getURL("dashboard.html"),
    minHeight: 300,
    maxHeight: 600,
  });
});
// dashboard.ts (embedded page)
// Report content height to the parent popup for dynamic resizing
function reportHeight(): void {
  const height = document.documentElement.scrollHeight;
  window.parent.postMessage({ type: "resize", height }, "*");
}

// Report on load and on content changes
window.addEventListener("load", reportHeight);
new MutationObserver(reportHeight).observe(document.body, {
  childList: true,
  subtree: true,
  attributes: true,
});

Gotchas:


Pattern 8: iframe-Based UI Injection Patterns

Instead of directly manipulating the host page DOM, inject a full UI as an iframe. This provides complete style isolation and avoids conflicts with the page’s CSS and JavaScript.

// ui-injector.ts
interface InjectionOptions {
  position: "bottom-right" | "bottom-left" | "top-right" | "top-left" | "full-overlay";
  width: string;
  height: string;
  page: string;
  draggable?: boolean;
}

class ExtensionUIInjector {
  private container: HTMLDivElement | null = null;
  private iframe: HTMLIFrameElement | null = null;
  private shadowRoot: ShadowRoot | null = null;

  inject(options: InjectionOptions): void {
    if (this.container) this.remove();

    this.container = document.createElement("div");
    this.container.id = `ext-ui-${crypto.randomUUID().slice(0, 8)}`;
    this.shadowRoot = this.container.attachShadow({ mode: "closed" });

    const positionStyles = this.getPositionStyles(options);

    const style = document.createElement("style");
    style.textContent = `
      :host {
        all: initial;
        position: fixed;
        ${positionStyles}
        width: ${options.width};
        height: ${options.height};
        z-index: 2147483647;
        font-family: system-ui, sans-serif;
      }
      .frame-wrapper {
        width: 100%;
        height: 100%;
        border-radius: 8px;
        overflow: hidden;
        box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
        resize: both;
      }
      iframe {
        width: 100%;
        height: 100%;
        border: none;
        background: white;
      }
      .drag-handle {
        height: 28px;
        background: #1a1a2e;
        cursor: move;
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 0 10px;
        user-select: none;
      }
      .drag-handle span {
        color: #ccc;
        font-size: 12px;
      }
      .close-btn {
        background: none;
        border: none;
        color: #ccc;
        cursor: pointer;
        font-size: 16px;
        padding: 0 4px;
      }
      .close-btn:hover {
        color: white;
      }
    `;

    const wrapper = document.createElement("div");
    wrapper.className = "frame-wrapper";

    if (options.draggable) {
      const handle = document.createElement("div");
      handle.className = "drag-handle";

      const title = document.createElement("span");
      title.textContent = "Extension Panel";
      handle.appendChild(title);

      const closeBtn = document.createElement("button");
      closeBtn.className = "close-btn";
      closeBtn.textContent = "\u00d7";
      closeBtn.addEventListener("click", () => this.remove());
      handle.appendChild(closeBtn);

      wrapper.appendChild(handle);
      this.enableDragging(handle);
    }

    this.iframe = document.createElement("iframe");
    this.iframe.src = chrome.runtime.getURL(options.page);

    wrapper.appendChild(this.iframe);
    this.shadowRoot.appendChild(style);
    this.shadowRoot.appendChild(wrapper);
    document.body.appendChild(this.container);

    this.setupMessageChannel();
  }

  private getPositionStyles(options: InjectionOptions): string {
    switch (options.position) {
      case "bottom-right":
        return "bottom: 16px; right: 16px;";
      case "bottom-left":
        return "bottom: 16px; left: 16px;";
      case "top-right":
        return "top: 16px; right: 16px;";
      case "top-left":
        return "top: 16px; left: 16px;";
      case "full-overlay":
        return "top: 0; left: 0; width: 100vw !important; height: 100vh !important;";
    }
  }

  private enableDragging(handle: HTMLElement): void {
    let isDragging = false;
    let startX = 0;
    let startY = 0;
    let startLeft = 0;
    let startTop = 0;

    handle.addEventListener("mousedown", (e: MouseEvent) => {
      isDragging = true;
      startX = e.clientX;
      startY = e.clientY;
      const rect = this.container!.getBoundingClientRect();
      startLeft = rect.left;
      startTop = rect.top;

      // Prevent iframe from capturing mouse events during drag
      if (this.iframe) this.iframe.style.pointerEvents = "none";
    });

    document.addEventListener("mousemove", (e: MouseEvent) => {
      if (!isDragging || !this.container) return;

      const dx = e.clientX - startX;
      const dy = e.clientY - startY;

      this.container.style.left = `${startLeft + dx}px`;
      this.container.style.top = `${startTop + dy}px`;
      this.container.style.right = "auto";
      this.container.style.bottom = "auto";
    });

    document.addEventListener("mouseup", () => {
      isDragging = false;
      if (this.iframe) this.iframe.style.pointerEvents = "auto";
    });
  }

  private setupMessageChannel(): void {
    window.addEventListener("message", (event: MessageEvent) => {
      if (event.source !== this.iframe?.contentWindow) return;

      switch (event.data.action) {
        case "close":
          this.remove();
          break;

        case "resize":
          if (this.container) {
            this.container.style.width = event.data.width;
            this.container.style.height = event.data.height;
          }
          break;

        case "forward-to-background":
          chrome.runtime.sendMessage(event.data.payload);
          break;
      }
    });
  }

  remove(): void {
    this.container?.remove();
    this.container = null;
    this.iframe = null;
    this.shadowRoot = null;
  }

  isVisible(): boolean {
    return this.container !== null;
  }

  toggle(options: InjectionOptions): void {
    if (this.isVisible()) {
      this.remove();
    } else {
      this.inject(options);
    }
  }
}

// Usage from content script
const injector = new ExtensionUIInjector();

chrome.runtime.onMessage.addListener((message) => {
  if (message.action === "toggle-panel") {
    injector.toggle({
      position: "bottom-right",
      width: "400px",
      height: "520px",
      page: "panel.html",
      draggable: true,
    });
  }
});

Gotchas:


Summary

Pattern Best For Key Constraint
Content Script to iframe Reading/modifying page iframes Cross-origin needs postMessage
Extension iframe in Shadow DOM Injecting UI into pages Requires web_accessible_resources
Cross-Origin Messaging Secure bidirectional communication Must validate origin and source
Sandboxed iframe Running untrusted code No chrome.* API access
Permissions and CSP Embedding external content Must configure frame-src in CSP
Detecting Page iframes Analyzing page structure Timing-sensitive, use MutationObserver
Popup Embedded iframes Rich popup interfaces 800x600px popup size limit
UI Injection Full extension panels on pages Shadow DOM for style isolation

See also: Content Script Isolation for understanding the isolated world that content scripts run in. Web Accessible Resources for configuring which extension files can be loaded from web pages. -e —

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.