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

Page Bridge — Talking to the Host's JavaScript World

~12 min · page-bridge, postMessage, custom-event, web-accessible-resources, world-main

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"You'll need this maybe one feature in ten. But when you need it, no amount of clever ISOLATED-world DOM access substitutes. Lesson 5 is the four legitimate ways content scripts and pages talk, with which to pick when."

When You Actually Need a Bridge

Most ClipDeck features (read DOM, read selection, ship to SW) live entirely in the ISOLATED world. The bridge is only needed when:

  • You need to read a page-defined global — window.React.version, window.__INITIAL_STATE__, a CMS's data-layer object.
  • You need to call a page-defined function — "trigger the site's own search," "hand a payload to the page's analytics queue."
  • You're integrating with a framework's runtime hooks (React DevTools-style work).

Everything else — DOM reads, DOM writes, event listening, storage round-trips — does not need a bridge. Reach for one only when ISOLATED genuinely cannot answer the question.

Pattern A — CustomEvent

Simplest. One world dispatches; the other listens. Works in either direction.

  • Dispatched on a DOM node (usually document or window).
  • Payload goes in detail, but with caveats: the detail object is shared, but anything richer than primitives may be wrapped/unwrapped across worlds — keep it to JSON-clonable shapes.
  • One-shot signaling: "the user just did X," "please refresh your sidebar."

Pattern B — window.postMessage

The DOM messaging API. Both worlds share the same window object (it's the DOM window, not a JS global), so they can post messages to it and listen on it. The message payload is structured-cloned, so plain data survives intact.

  • Request/response works naturally: post a request with a unique id, listen for a response with the same id.
  • Cross-cuts iframe boundaries too (with explicit origin checks).
  • Sentinel pattern is mandatory — the page can post anything, so filter on a known marker field before trusting the payload.

Pattern C — Injected Script Tag (Legacy)

The pre-Chrome-111 path for getting code into the page's JS world:

  1. Write injected.js as a separate file inside the extension.
  2. List it in web_accessible_resources in the manifest so the page is allowed to load it.
  3. From the content script, create a <script src="chrome-extension://.../injected.js"> and append it to document.documentElement.
  4. The script tag executes in the page's world. Communicate back via CustomEvent or postMessage.

Still works in modern Chrome, still useful when you need MAIN-world execution from a declarative content script that hasn't migrated to world: 'MAIN'.

Pattern D — chrome.scripting with world: 'MAIN' (Modern)

Lesson 3 introduced this. The SW calls chrome.scripting.executeScript({ target, world: 'MAIN', func }) and the function runs directly in the page's JS world, returning its result. No script tag, no web_accessible_resources, no relay code.

This is the cleanest path for one-shot MAIN-world calls triggered by an extension event (toolbar click, context menu, message from popup). When you're declaratively injecting and need MAIN, Chrome 111+ also lets you set "world": "MAIN" in the content_scripts manifest entry.

Sentinel and Origin — The Two Discipline Habits

Anything that listens for messages from arbitrary pages must guard:

  • Sentinel field. Every message your script sends carries { source: "clipdeck-content" } or similar. Every listener checks that field first. A malicious page can post a fake message with the same sentinel, but the standard practice prevents most accidental cross-talk with other libraries.
  • Origin check. For window.postMessage, validate event.source === window (the message came from this same window, not an iframe) and event.origin matches the expected URL when relevant.
  • Treat MAIN-world messages as untrusted. Once data crosses from MAIN to ISOLATED, it came from somewhere the page can influence. Validate types, lengths, and shapes before storing.
ISOLATED for almost everything. MAIN for the rare moments when you need the page's actual JavaScript. Pick the simplest bridge that solves the problem — CustomEvent for signals, postMessage for request/response, chrome.scripting with world MAIN for one-shot reads.
The '*' targetOrigin trap. window.postMessage(payload, '*') sends the message to every listener, regardless of origin. Convenient during development, dangerous in production — any third-party script that registered a message listener on the same window will see your payload. For ISOLATED↔MAIN inside one tab, '*' is acceptable because the same-tab boundary already isolates you from external sites; for ANY cross-frame messaging, set the specific target origin you intend.

Code

CustomEvent listener — content script catches a page-dispatched signal·javascript
// content.js (ISOLATED) — listen for a CustomEvent the page dispatches
document.addEventListener("clipdeck:react-version", (event) => {
  const version = event.detail?.version;
  console.log("[ClipDeck content] page reported React version:", version);
  chrome.runtime.sendMessage({ type: "reactVersion", version });
});

// In the page (MAIN world), the page would do:
// document.dispatchEvent(
//   new CustomEvent('clipdeck:react-version', { detail: { version: React.version } })
// );
// The injected MAIN-world script from Pattern D below provides this.
Promise-wrapped request/response via window.postMessage with sentinel + id·javascript
// content.js (ISOLATED) — request/response over window.postMessage
function askPage(type, payload) {
  return new Promise((resolve, reject) => {
    const id = Math.random().toString(36).slice(2);
    function onResponse(event) {
      if (event.source !== window) return;
      const data = event.data;
      if (!data || data.source !== "clipdeck-page" || data.id !== id) return;
      window.removeEventListener("message", onResponse);
      if (data.error) reject(new Error(data.error));
      else resolve(data.payload);
    }
    window.addEventListener("message", onResponse);
    window.postMessage({ source: "clipdeck-content", id, type, payload }, "*");
    setTimeout(() => {
      window.removeEventListener("message", onResponse);
      reject(new Error("timeout"));
    }, 2000);
  });
}

// Usage (after a MAIN-world helper is in place):
// const version = await askPage('getReactVersion');
MAIN-world one-shot — read window.React.version cleanly·javascript
// background.js — Pattern D, the modern MAIN-world one-shot
async function readPageReactVersion(tabId) {
  const [result] = await chrome.scripting.executeScript({
    target: { tabId },
    world: "MAIN",
    func: () => {
      try {
        // Page's own React reference. Many sites attach it.
        return window.React?.version ?? null;
      } catch (err) {
        return null;
      }
    },
  });
  return result?.result ?? null;
}

// Caller (in onMessage from popup, for instance):
// const version = await readPageReactVersion(tab.id);

External links

Exercise

Add the third code block (readPageReactVersion) to clipdeck/background.js and wire it to a message handler: when the SW receives {type:'getReactVersion'} from the popup, call readPageReactVersion(sender.tab?.id || (await chrome.tabs.query({active:true,currentWindow:true}))[0].id) and respond with the version. Add a Detect React button to clipdeck/popup.html that sends the message. Test on https://react.dev (likely returns a version) and on https://wikipedia.org (likely returns null). The interesting moment is testing on a site you know uses React but that doesn't expose window.React — most production React apps run with React bundled but not assigned globally, so the result is null. That's the realism: MAIN-world access lets you see what's there, not what should be there.
Hint
If executeScript errors with Cannot access contents of the page, the URL is one of Chrome's restricted ones (chrome://, the Chrome Web Store itself, etc.); navigate to a real http/https page first. If your popup shows null on a page that visibly runs React, that page bundles React without attaching it to window — try checking for window.__REACT_DEVTOOLS_GLOBAL_HOOK__ instead, which most bundled React apps do set. If you want richer page-side probing later, write a small MAIN-world helper that walks the React fiber tree and reports back via postMessage — Pattern B's request/response shape handles that cleanly.

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.