C.W.K.
Stream
Lesson 05 of 08 · published

Anchor 5 — Side Panel as an Iframe Bridge

~12 min · side-panel, iframe, postMessage, bridge

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"ChromeEmbed's side panel is an HTML page with a single iframe pointed at cwkPippa. Lesson 5 is the bridge that lets the iframe — which doesn't have chrome.* — still get the context the SW collects, via window.postMessage between extension and iframe."

The Side Panel HTML

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Pippa</title>
    <style>
      html, body { margin: 0; width: 100%; height: 100%; background: #0f0f23; }
      iframe { width: 100%; height: 100vh; border: 0; display: block; }
    </style>
  </head>
  <body>
    <iframe id="pippa-frame" src="http://localhost:5173/embed/panel"></iframe>
    <script src="sidepanel.js"></script>
  </body>
</html>

The HTML is almost entirely chrome (full-height iframe, dark background while loading). The src points at cwkPippa's /embed/panel route. Frame-src in the manifest's extension_pages CSP allows the localhost URL to load.

The Identity Boundary

The iframe is a different origin from the extension page (the extension page is on chrome-extension://<id>/, the iframe is on http://localhost:5173/). Consequences:

  • The iframe cannot directly read chrome.*. It's a regular web page from its perspective.
  • The extension page (sidepanel.html + sidepanel.js) CAN read chrome.* — same origin as the rest of the extension.
  • The two communicate via window.postMessage on the shared DOM window.

This is the trade ChromeEmbed makes: get all of cwkPippa's UI for free by loading it as an iframe, pay the cost of needing a bridge for any chrome.* data.

The Bridge — sidepanel.js

40 lines total. The full surface:

const frame = document.getElementById('pippa-frame');

function postToPanel(message) {
  frame?.contentWindow?.postMessage(message, '*');
}

async function requestContext(requestId) {
  const response = await chrome.runtime
    .sendMessage({ type: 'pippa:request-context', requestId })
    .catch(() => null);
  if (response?.type === 'pippa:host-context') {
    postToPanel({ ...response, requestId: response.requestId || requestId });
  } else if (requestId) {
    postToPanel({ type: 'pippa:no-context', requestId });
  }
}

chrome.runtime.onMessage.addListener((message) => {
  if (message?.type === 'pippa:host-context') {
    postToPanel(message);
  }
});

window.addEventListener('message', (event) => {
  if (event.data?.type === 'pippa:request-context') {
    requestContext(event.data.requestId);
  }
});

frame?.addEventListener('load', () => requestContext());
requestContext();

Four Flows

  1. Iframe asks the bridge for context — iframe does window.parent.postMessage({type:'pippa:request-context', requestId}, '*'); bridge's message listener catches it, asks SW, posts result back to iframe.
  2. SW pushes context to the panel — when content script reports new context, SW broadcasts via chrome.runtime.sendMessage; bridge's chrome.runtime.onMessage listener catches it, posts to iframe.
  3. Iframe boots — on iframe load and on bridge load, the bridge calls requestContext() to seed the iframe with whatever context the SW already has.
  4. Future bidirectional — if the iframe ever sends 'send this to the SW,' the bridge's message listener can route that to chrome.runtime.sendMessage. v0.1 doesn't have that path yet.

The Loose Origin

The postMessage calls use '*' as target origin. In a public page that'd be a security risk — any iframe could receive your payload. Inside a panel that you control loading, it's acceptable: the only iframe is the one you spawned. Production-hardening would change '*' to the actual cwkPippa origin (and the iframe would do the same for messages back to its parent).

What the Iframe Side Looks Like

Inside cwkPippa's /embed/panel route, code roughly mirrors the bridge:

// Inside cwkPippa, in the embed/panel React component
useEffect(() => {
  const requestId = Math.random().toString(36).slice(2);
  function onMessage(event) {
    if (event.data?.requestId === requestId && event.data?.type === 'pippa:host-context') {
      setContext(event.data.payload);
    }
  }
  window.addEventListener('message', onMessage);
  window.parent.postMessage({ type: 'pippa:request-context', requestId }, '*');
  return () => window.removeEventListener('message', onMessage);
}, []);

Track 7 Lesson 6's preview-and-confirm Promise pattern would generalize this nicely; ChromeEmbed v0.1 keeps it inline.

Why Iframe Over Native Render

The alternative would be: re-implement cwkPippa's panel chat inside the extension package using a React build that lives in dist/. That works but means every cwkPippa change requires also rebuilding the extension. The iframe path means cwkPippa is the source of truth; the extension is purely a delivery mechanism.

For ChromeEmbed v0.1 the iframe wins by a mile because cwkPippa is already a working SPA with months of UI investment. Building 'extension-shaped cwkPippa' would have doubled the surface area. v2 might invert — once the embed API stabilizes, an offline-capable bundled UI could matter — but that's deliberately deferred.

Side panel = HTML with an iframe to your real app. The bridge between chrome.* and the iframe is window.postMessage. Three short flows: iframe asks, SW pushes, iframe boots. Forty lines of bridge handles all of them.
The dev-vs-prod URL choice. sidepanel.html hardcodes localhost:5173. For prod you'd swap to cwkPippa's deployed URL. Both must be in frame-src; both must be reachable when the panel renders. v0.1 ships with localhost for dev convenience; a Track 8 build step could swap based on a NODE_ENV-style flag.

Code

sidepanel.js — entire file: chrome.runtime ↔ iframe postMessage bridge·javascript
// embeds/chrome/sidepanel.js — full 40-line bridge
const frame = document.getElementById('pippa-frame');

function postToPanel(message) {
  frame?.contentWindow?.postMessage(message, '*');
}

async function requestContext(requestId) {
  const response = await chrome.runtime
    .sendMessage({ type: 'pippa:request-context', requestId })
    .catch(() => null);
  if (response?.type === 'pippa:host-context') {
    postToPanel({ ...response, requestId: response.requestId || requestId });
  } else if (requestId) {
    postToPanel({ type: 'pippa:no-context', requestId });
  }
}

chrome.runtime.onMessage.addListener((message) => {
  if (message?.type === 'pippa:host-context') {
    postToPanel(message);
  }
});

window.addEventListener('message', (event) => {
  if (event.data?.type === 'pippa:request-context') {
    requestContext(event.data.requestId);
  }
});

frame?.addEventListener('load', () => requestContext());
requestContext();

External links

Exercise

Read embeds/chrome/sidepanel.html and sidepanel.js end to end. In the panel's DevTools console, type frame.contentWindow.postMessage({type:'pippa:request-context', requestId:'test123'}, '*') and watch the bridge route the request to the SW, the SW return context, and the bridge post the response back to the iframe. The roundtrip is the entire bridge surface — three short hops, one promise resolving. Then try chrome.runtime.sendMessage({type:'pippa:request-context', requestId:'test456'}) from the panel's DevTools — you'll get the SW response directly because you ARE same-origin as the extension. The iframe doesn't have that luxury, which is why the bridge exists.
Hint
If the postMessage from your DevTools doesn't trigger anything, double-check the panel's iframe id (pippa-frame) and that you're typing in the panel's DevTools (not the iframe's). Use the Console context dropdown to switch between extension page and iframe — you'll see different chrome.* availability in each. The lesson: every layer has different powers; bridging them is what extensions do for a living.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.