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

Anchor 2 — Background 가 message bus

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

Level 0Extension 입덕
0 XP0/54 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

readSelectionFromPage 가 programmatic chrome.scripting.executeScript 호출. Content script 가 selection 보고 안 했어도 (busy 거나 crash 했을 수도), SW 가 Chrome 한테 어떤 frame 의 live selection 이든 read 요청 가능. Fallback 이 message-based content-script query 옆에서 돌고, 다음 두 결과 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 logic 구현 — 그게 panel iframe content, SW 의 job 아님.

안 추가하는 규율이 SW 작고 obviously 올바르게 유지. 모든 줄이 세 message type 중 하나로 정당화; 'just in case' 로 있는 거 없음.

Background.js 가 bus: 세 message type, 한 in-memory cache, business logic 없음. Panel 이 work, SW 가 wire carry. 120 줄이 충분.
Map 위해 왜 chrome.storage.session 안? chrome.storage.session 이 SW eviction 살아남지만 browser close 에 죽고, read/write 가 async. 현재 Map 이 in-memory only; 매 SW eviction 에 죽음. 매 eviction-then-ask 경로가 content script re-query 해서 fine. 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());

External links

Exercise

Full embeds/chrome/background.js (120 줄) read. 한 round-trip trace: popup 이 pippa:open-panel 보냄 → SW 가 받음 → chrome.sidePanel.open 호출 → ok 응답. 다음 또 trace: content script 가 scroll 후 pippa:host-context push → SW 가 latestContextByTab 에 merge → 응답 안 돌아옴 (request/response 아닌 push). 어느 handler 가 true (async sendResponse) 반환하고 false (sync 나 응답 없음) 반환하는지 주목. 그 return value 의 규율이 빠른 event interleaving 아래 bus 를 올바르게 유지.
Hint
return true vs return false 잘못 read 하면 handler 가 missing 인 줄 — return value 가 Chrome 한테 listener 가 나중에 sendResponse 부를 의도인지 (true), 이미 그랬는지 (false, default) 알려 줘. Track 2 Lesson 5 의 silent-timeout 함정이 이거 잘못 받는 결과 설명. ChromeEmbed file 이 한 자리에서 read 가능한 작은 크기; lesson body 다시 read 전에 그렇게.

Progress

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

댓글 0

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

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