본문 바로가기
C.W.K.
Stream
Lesson 02 of 10 · published

Anchor 2 — Background가 message bus

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

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"ChromeEmbed의 background.js가 한 job 하는 120 줄: content script에서 context 받고, tab 별 최신 거 hold, request 시 side panel 한테 건넴. Lesson 2가 세 context 묶는 bus 패턴."

세 message type

전체 bus가 세 가지 traffic:

  • pippa:host-context — user가 scroll, select, focus 할 때 content script가 push. Payload가 full viewport snapshot (Lesson 6).
  • pippa:request-context — 현재 context 원할 때 (mount, iframe load, 명시적 refresh) side panel이 pull.
  • pippa:open-panel — user가 action icon click 할 때 popup이 push. SW가 user-gesture-derive 된 호출로 chrome.sidePanel.open 호출해서 응답.

그게 전부. Clip storage 없음, chat 상태 없음, soul/brain wiring 없음 — 그것들이 cwkPippa (panel iframe 통해 load) 에 살아. SW가 얇은 coordinator.

Per-tab Map

const latestContextByTab = new Map()가 SW의 유일한 상태. tabId로 key, 가장 최근 host-context payload로 value. 두 write:

  • Content script가 pippa:host-context push 할 때 handler가 이전 entry와 merge (이전 push에서 selection persistence carry forward. sub-frame push가 top-frame 상태 blow away 안 함).
  • SW가 panel 위해 fresh context pull (requestContextFromActiveTab) 할 때 결과를 Map 에 저장.

한 read: panel이 context 요청할 때 SW가 가장 최근 cached entry 반환하면서 live content script 도 re-query — best of both worlds. SW가 push와 ask 사이 evict 됐으면 Map이 빈 채로 다시 시작. re-query 경로가 fill.

Merge logic

주의 깊게 read 가치:

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

Sub-frame이 push 하면 (user page 안 iframe), top-frame의 viewport 텍스트와 URL를 blow away 안 시킴. 그것의 timestamp와 selection 만 take. Top-frame이 push 하면 모든 것 새 상태로 받아들임. 이게 iframe 가진 실제 page (Stack Overflow embed, YouTube video) 가 경쟁 context payload push 하는 거 본 후에만 등장하는 nuance.

Selection fallback

readSelectionFromPagechrome.scripting.executeScript를 직접 부르는 길이야. content script가 선택을 안 올려 줬더라도 — 바쁘거나 죽었을 수도 있으니까 — SW가 Chrome 한테 어느 frame 이든 지금 선택을 읽어 달라고 부탁할 수 있어. 이 보조 경로가 메시지로 물어보는 길과 나란히 돌면서,다음 두 결과 merge.

Track 6의 activeTab + scripting combo가 이걸 standing host permission 없이 동작하게. User-gesture-trigger 된 scripting 호출이 정확히 activeTab cover.

Side panel open 경로

Popup이 SW 한테 panel 열기 요청할 때:

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

windowId form이 critical — tabId 대신 전달하면 그 한 tab 으로 panel scope. windowId가 전체 window 위해 열기. .catch(() => {})가 open 완료 전 user가 panel dismiss 하는 race를 silently 흡수. Track 4 Lesson 2의 user-gesture-rule 적용: popup click이 gesture, SW가 message 통해 carry.

이 SW 에 없는 것

ChromeEmbed v0.1이 일부러 안 하는 것:

  • SW lifetime 너머 context persist — Chrome evict 하면 Map 비움.
  • Context history 유지 — tab 당 최신만.
  • cwkPippa의 backend와 대화 — iframe이 모든 real work, SW는 message 만 forward.
  • clip 이나 soul, brain 로직을 여기 넣는 것 — 그건 panel iframe 안쪽이 할 일이지 SW 몫이 아니야.

안 넣는 절제가 SW를 작게, 그리고 한눈에 맞다고 알아볼 수 있게 지켜 줘. 여기 있는 모든 줄이 메시지 세 종류 중 하나로 설명이 되고, '혹시 몰라서' 남아 있는 건 하나도 없어.

background.js는 버스야. 메시지 세 종류, 메모리 캐시 하나, 그리고 업무 로직은 없음. 일은 panel이 하고 SW는 선만 나르는 거지. 120 줄이면 충분해.
왜 Map 대신 chrome.storage.session을 안 썼을까? chrome.storage.session은 SW가 쫓겨나도 살아남지만 브라우저를 닫으면 죽고, 읽고 쓰는 게 async 야. 지금 쓰는 Map은 순전히 메모리에만 있어서 SW가 쫓겨날 때마다 같이 사라지고. 그래도 괜찮은 건, 쫓겨난 다음에 누가 물어보면 content script 한테 다시 물어보게 돼 있어서. SW lifetime 너머 history 원하면 '맞는' 답 바뀜, 하지만 'current context only' 엔 Map이 더 단순.

Code

background.js — 세 message type + cache 보여 주는 abridged version·javascript
// embeds/chrome/background.js — bus (full file, 가볍게 annotated)
const latestContextByTab = new Map();

chrome.runtime.onInstalled.addListener(() => {
  // Popup 이 action click; panel 이 popup 의 open-panel 메시지 통해 열림.
  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 ... (sub-frame nuance 위해 lesson body 참조)
      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와 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

실제 background.js에서 pippa:request-context, pippa:capture-screenshot, pippa:set-display-mode 세 route를 끝까지 따라가. 각각 restricted-page guard, active-tab lookup, content-script 또는 Chrome API hop, response envelope, literal true 반환 여부를 표시해.
Hint
옛 snapshot의 line 수나 message 수를 세지 마. Live listener를 검색해. Async sendResponse branch가 true를 반환하고 payload가 current top page와 맞는지가 invariant야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.