Skip to content
C.W.K.
Stream
Lesson 02 of 10 · published

Anchor 2 — Background as the Message Bus

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

Level 0Extension Curious
0 XP0/56 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());
v0.2.1 checkpoint — background.js — ephemeral tab cache plus session-scoped identity·javascript
const latestContextByTab = new Map();
let browserSessionIdPromise = null;

async function browserSessionId() {
  if (!browserSessionIdPromise) {
    browserSessionIdPromise = (async () => {
      const { pippaBrowserSessionId } =
        await chrome.storage.session.get("pippaBrowserSessionId").catch(() => ({}));
      if (pippaBrowserSessionId) return pippaBrowserSessionId;
      const next = `session-${crypto.randomUUID()}`;
      await chrome.storage.session.set({ pippaBrowserSessionId: next });
      return next;
    })();
  }
  return browserSessionIdPromise;
}

function cachedPayloadForTab(tab) {
  const payload = latestContextByTab.get(tab.id) || null;
  if (!payload || payload.isTopFrame === false || payload.source !== tab.url) {
    latestContextByTab.delete(tab.id);
    return null;
  }
  return payload;
}

External links

Exercise

Read the live background.js and trace three routes end to end: pippa:request-context, pippa:capture-screenshot, and pippa:set-display-mode. For each, mark the restricted-page guard, the active-tab lookup, the content-script or Chrome API hop, the response envelope, and whether the handler returns literal true.
Hint
Do not count lines or message types from an old snapshot. Search the live listener. The important invariant is that every async sendResponse branch returns true and every payload is checked against the current top page.

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.