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

Anchor 2 — Background as the Message Bus

~12 min · background, service-worker, messaging, case-study

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"ChromeEmbed's background.js is 120 lines doing one job: receive context from content scripts, hold the latest per tab, hand it to the side panel on request. Lesson 2 is the bus pattern that ties the three contexts together."

The Three Message Types

The whole bus traffics in three:

  • pippa:host-context — pushed by content scripts when the user scrolls, selects, or focuses. Payload is the full viewport snapshot (Lesson 6).
  • pippa:request-context — pulled by the side panel when it wants the current context (mount, iframe load, explicit refresh).
  • pippa:open-panel — pushed by the popup when the user clicks the action icon. The SW responds by calling chrome.sidePanel.open with a user-gesture-derived call.

That's it. No clip storage, no chat state, no soul/brain wiring — those live in cwkPippa (loaded via the panel's iframe). The SW is a thin coordinator.

The Per-Tab Map

const latestContextByTab = new Map() is the only state in the SW. Keyed by tabId, valued by the most recent host-context payload. Two writes:

  • When a content script pushes pippa:host-context, the handler merges it with the previous entry (selection persistence from earlier pushes carries forward; sub-frame pushes don't blow away top-frame state).
  • When the SW pulls a fresh context for the panel (requestContextFromActiveTab), it stores the result in the Map.

One read: when the panel asks for context, the SW returns the latest cached entry while also re-querying the live content script — best of both worlds. If the SW was evicted between push and ask, the Map starts empty again; the re-query path fills it.

The Merge Logic

Worth reading carefully:

const next = incomingIsSubframe && previous
  ? {
      ...previous,
      snapshot_at: incoming.snapshot_at || previous.snapshot_at,
      selection: incoming.selection || previous.selection,
    }
  : {
      ...(previous || {}),
      ...incoming,
    };

If a sub-frame is pushing (an iframe inside the user's page), don't let it blow away the top-frame's viewport text and URL. Just take its timestamp and its selection. If the top-frame pushes, accept everything as the new state. This is the kind of nuance that emerges only after you've watched real pages with iframes (Stack Overflow embeds, YouTube videos) push competing context payloads.

The Selection Fallback

readSelectionFromPage is a programmatic chrome.scripting.executeScript call. Even if the content script hasn't reported a selection (maybe it's busy, maybe it crashed), the SW can still ask Chrome to read the live selection from any frame. The fallback runs alongside the message-based content-script query, then both results merge.

Track 6's activeTab + scripting combo is what makes this work without standing host permissions. The user-gesture-triggered scripting call is exactly what activeTab covers.

The Side Panel Open Path

When the popup asks the SW to open the panel:

chrome.windows.getCurrent().then((windowInfo) => {
  if (windowInfo.id !== undefined) {
    chrome.sidePanel.open({ windowId: windowInfo.id }).catch(() => {});
  }
  sendResponse({ ok: true });
});

The windowId form is critical — passing tabId instead would scope the panel to that one tab; windowId opens it for the whole window. The .catch(() => {}) silently absorbs the race where the user dismisses the panel before the open completes. Track 4 Lesson 2's user-gesture-rule applies: the popup click is the gesture, and the SW carries it through the message.

What's NOT in This SW

ChromeEmbed v0.1 deliberately doesn't:

  • Persist contexts beyond the SW lifetime — once Chrome evicts, the Map empties.
  • Maintain a history of contexts — only the latest per tab.
  • Talk to cwkPippa's backend — the iframe does all real work; the SW just forwards messages.
  • Implement any clip / soul / brain logic — that's the panel's iframe content, not the SW's job.

The discipline of NOT adding things keeps the SW small and obviously correct. Every line is justified by one of the three message types; nothing is there 'just in case.'

Background.js is a bus: three message types, one in-memory cache, no business logic. The panel does the work; the SW carries the wires. 120 lines is enough.
Why not chrome.storage.session for the Map? chrome.storage.session survives SW eviction but dies on browser close, and reads/writes are async. The current Map is in-memory only; it dies on every SW eviction. That's fine because every eviction-then-ask path re-queries the content script. The 'right' answer changes if you ever want history beyond the SW lifetime, but for 'current context only,' the Map is simpler.

Code

background.js — abridged version showing the three message types + cache·javascript
// embeds/chrome/background.js — the bus (full file, lightly annotated)
const latestContextByTab = new Map();

chrome.runtime.onInstalled.addListener(() => {
  // Popup is the action click; panel opens via the popup's open-panel message.
  if (chrome.sidePanel?.setPanelBehavior) {
    chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }).catch(() => {});
  }
});

async function activeTab() {
  let tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
  if (!tabs.length) tabs = await chrome.tabs.query({ active: true, currentWindow: true });
  return tabs[0] || null;
}

async function readSelectionFromPage(tabId) {
  try {
    const results = await chrome.scripting.executeScript({
      target: { tabId }, allFrames: true,
      func: () => (window.getSelection?.().toString() || '').trim(),
    });
    return results.map((r) => r?.result).find((r) => typeof r === 'string' && r) || '';
  } catch { return ''; }
}

async function requestContextFromActiveTab() {
  const tab = await activeTab();
  if (!tab?.id) return null;
  let payload = latestContextByTab.get(tab.id) || null;
  try {
    const response = await chrome.tabs.sendMessage(tab.id, { type: 'pippa:request-context' });
    if (response?.type === 'pippa:host-context') payload = response.payload;
  } catch {}
  const directSelection = await readSelectionFromPage(tab.id);
  if (directSelection) {
    payload = { ...(payload || {}), selection: directSelection, snapshot_at: new Date().toISOString() };
  }
  if (payload) latestContextByTab.set(tab.id, payload);
  return payload;
}

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === 'pippa:host-context') {
    const tabId = sender.tab?.id;
    if (typeof tabId === 'number') {
      // merge-and-cache logic ... (see lesson body for the sub-frame nuance)
      latestContextByTab.set(tabId, { ...(latestContextByTab.get(tabId) || {}), ...message.payload });
    }
    sendResponse({ ok: true });
    return false;
  }
  if (message?.type === 'pippa:request-context') {
    requestContextFromActiveTab().then((payload) => {
      sendResponse(payload
        ? { type: 'pippa:host-context', payload, requestId: message.requestId }
        : { type: 'pippa:no-context', requestId: message.requestId });
    });
    return true;
  }
  if (message?.type === 'pippa:open-panel') {
    chrome.windows.getCurrent().then((w) => {
      if (w.id !== undefined) chrome.sidePanel.open({ windowId: w.id }).catch(() => {});
      sendResponse({ ok: true });
    });
    return true;
  }
  return false;
});

chrome.tabs.onActivated.addListener(() => requestContextFromActiveTab());

External links

Exercise

Read the full embeds/chrome/background.js (120 lines). Trace one round-trip: the popup sends pippa:open-panel → the SW receives it → calls chrome.sidePanel.open → responds ok. Then trace another: the content script pushes pippa:host-context after a scroll → the SW merges into latestContextByTab → no response goes back (push, not request/response). Note which handlers return true (async sendResponse) vs false (sync or no response). The discipline of those return values is what keeps the bus correct under fast event interleaving.
Hint
If you misread return true vs return false you'll think a handler is missing — the return value tells Chrome whether the listener intends to call sendResponse later (true) or has already done so (false, the default). Track 2 Lesson 5's silent-timeout trap explains the consequence of getting this wrong. The ChromeEmbed file is small enough to read in one sitting; do that before re-reading the lesson body.

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.