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

Anchor 5 — Side panel이 iframe bridge

~12 min · side-panel, iframe, postMessage, bridge, v0.2.1

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"ChromeEmbed의 side panel이 cwkPippa 가리키는 iframe 하나 가진 HTML page. Lesson 5가 chrome.* 없는 iframe이 extension과 iframe 사이 window.postMessage 통해서도 SW가 모으는 context 얻게 해 주는 bridge."

Side panel HTML

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Pippa</title>
    <style>
      html, body { margin: 0; width: 100%; height: 100%; background: #0f0f23; }
      iframe { width: 100%; height: 100vh; border: 0; display: block; }
    </style>
  </head>
  <body>
    <iframe id="pippa-frame" src="http://localhost:5173/embed/panel"></iframe>
    <script src="sidepanel.js"></script>
  </body>
</html>

HTML이 거의 전적으로 chrome (full-height iframe, load 중 dark background). src가 cwkPippa의 /embed/panel route 가리킴. manifest의 extension_pages CSP의 frame-src가 localhost URL load 허용.

Identity 경계

Iframe이 extension page와 다른 origin (extension page가 chrome-extension://<id>/ 에, iframe이 http://localhost:5173/ 에). 결과:

  • iframe은 chrome.*를 직접 못 봐. 그 안에서 보면 자기는 그냥 평범한 웹 페이지거든.
  • Extension page (sidepanel.html + sidepanel.js) 가 chrome.* read CAN — extension 나머지와 같은 origin.
  • 둘이 공유 DOM window의 window.postMessage 통해 communicate.

이게 ChromeEmbed가 만드는 거래: iframe 으로 load 해서 cwkPippa의 UI 모두 무료로 얻기, 어떤 chrome.* data 든 bridge 필요한 비용 지불.

Bridge — sidepanel.js

총 40 줄. Full surface:

const frame = document.getElementById('pippa-frame');

function postToPanel(message) {
  frame?.contentWindow?.postMessage(message, '*');
}

async function requestContext(requestId) {
  const response = await chrome.runtime
    .sendMessage({ type: 'pippa:request-context', requestId })
    .catch(() => null);
  if (response?.type === 'pippa:host-context') {
    postToPanel({ ...response, requestId: response.requestId || requestId });
  } else if (requestId) {
    postToPanel({ type: 'pippa:no-context', requestId });
  }
}

chrome.runtime.onMessage.addListener((message) => {
  if (message?.type === 'pippa:host-context') {
    postToPanel(message);
  }
});

window.addEventListener('message', (event) => {
  if (event.data?.type === 'pippa:request-context') {
    requestContext(event.data.requestId);
  }
});

frame?.addEventListener('load', () => requestContext());
requestContext();

네 flow

  1. Iframe이 bridge 한테 context 요청 — iframe이 window.parent.postMessage({type:'pippa:request-context', requestId}, '*'). bridge의 message listener가 잡고, SW 한테 묻고, 결과를 iframe 으로 다시 post.
  2. SW가 panel 에 context push — content script가 새 context 보고할 때, SW가 chrome.runtime.sendMessage 통해 broadcast. bridge의 chrome.runtime.onMessage listener가 잡고 iframe 에 post.
  3. Iframe boot — iframe load와 bridge load 시 bridge가 requestContext() 호출해서 SW가 이미 가진 context로 iframe seed.
  4. 나중엔 양방향으로 — iframe이 'SW 한테 이거 좀 전해 줘' 하고 보내면, bridge의 listener가 chrome.runtime.sendMessage로 넘겨 줄 수 있어. v0.1 에는 아직 그 길이 안 뚫려 있고.

Loose origin

지금 postMessage는 받는 쪽 origin 에 '*'를 쓰고 있어. 공개된 페이지였으면 이건 위험해. 아무 iframe 이나 그 내용을 받아 볼 수 있으니까. 다만 내가 뭘 띄울지 통제하는 panel 안이라면 봐줄 만해. 거기 있는 iframe은 내가 띄운 그거 하나거든. 제대로 조일 땐 '*'를 실제 cwkPippa origin 으로 바꿔. 부모로 돌아오는 메시지도 iframe 쪽에서 똑같이 좁혀 주고.

Iframe 쪽 모습

cwkPippa의 /embed/panel route 안, 코드가 대략 bridge mirror:

// cwkPippa 안, embed/panel React component
useEffect(() => {
  const requestId = Math.random().toString(36).slice(2);
  function onMessage(event) {
    if (event.data?.requestId === requestId && event.data?.type === 'pippa:host-context') {
      setContext(event.data.payload);
    }
  }
  window.addEventListener('message', onMessage);
  window.parent.postMessage({ type: 'pippa:request-context', requestId }, '*');
  return () => window.removeEventListener('message', onMessage);
}, []);

Track 7 Lesson 6의 preview-and-confirm Promise 패턴이 이걸 nicely generalize 가능. ChromeEmbed v0.1이 inline 유지.

왜 native render 대신 iframe

다른 길도 있었어. dist/ 에 들어가는 React build를 써서 cwkPippa의 chat 화면을 extension 안에 다시 만드는 거지. 되긴 되는데, cwkPippa를 고칠 때마다 extension 도 같이 다시 빌드해야 해. iframe 으로 가면 cwkPippa가 계속 원본으로 남고, extension은 순전히 그걸 실어 나르는 통로가 돼.

ChromeEmbed v0.1 엔 iframe이 멀찌감치 이김 — cwkPippa가 이미 몇 달 UI 투자 가진 working SPA. 'extension-shape cwkPippa' build가 surface area 두 배. v2가 invert 가능 — embed API가 안정화되면, offline-capable bundled UI가 중요할 수도 — 그건 일부러 defer.

Side panel = 실제 app 으로의 iframe 가진 HTML. chrome.* 와 iframe 사이 bridge가 window.postMessage. 세 짧은 flow: iframe ask / SW push / iframe boot. 40 줄 bridge가 다 처리.
개발용 주소냐 배포용 주소냐. 지금 sidepanel.html 에는 localhost:5173이 박혀 있어. 배포할 땐 cwkPippa의 실제 주소로 갈아야 하고. 둘 다 frame-src 에 적혀 있어야 하고, panel을 그릴 때 실제로 닿을 수 있어야 해. v0.1은 개발이 편하라고 localhost를 그대로 달고 나갔어. Track 8의 빌드 단계에서 NODE_ENV 같은 플래그로 갈아 끼우p 가능.

Code

sidepanel.js — entire file: chrome.runtime ↔ iframe postMessage bridge·javascript
// embeds/chrome/sidepanel.js — full 40-줄 bridge
const frame = document.getElementById('pippa-frame');

function postToPanel(message) {
  frame?.contentWindow?.postMessage(message, '*');
}

async function requestContext(requestId) {
  const response = await chrome.runtime
    .sendMessage({ type: 'pippa:request-context', requestId })
    .catch(() => null);
  if (response?.type === 'pippa:host-context') {
    postToPanel({ ...response, requestId: response.requestId || requestId });
  } else if (requestId) {
    postToPanel({ type: 'pippa:no-context', requestId });
  }
}

chrome.runtime.onMessage.addListener((message) => {
  if (message?.type === 'pippa:host-context') {
    postToPanel(message);
  }
});

window.addEventListener('message', (event) => {
  if (event.data?.type === 'pippa:request-context') {
    requestContext(event.data.requestId);
  }
});

frame?.addEventListener('load', () => requestContext());
requestContext();
v0.2.1 checkpoint — sidepanel.js — 현재 origin resolution과 postMessage debt·javascript
const PANEL_ORIGIN_CANDIDATES = PIPPA_PANEL_ORIGINS;

async function resolvePanelOrigin() {
  const stored = await readStoredPanelOrigin();
  const candidates = stored === "auto"
    ? PANEL_ORIGIN_CANDIDATES
    : [stored, ...PANEL_ORIGIN_CANDIDATES.filter((x) => x !== stored)];
  for (const origin of candidates) {
    if (await canReachPanelOrigin(origin)) return origin;
  }
  return stored === "auto" ? PANEL_ORIGIN_CANDIDATES[0] : stored;
}

function postToPanel(message) {
  // Current implementation still uses "*". Treat this as bounded debt:
  // validate event.source, exact allowed origin, type, requestId, and schema.
  frame?.contentWindow?.postMessage(message, "*");
}

External links

Exercise

sidepanel.html, pippa-hosts.js, sidepanel.js를 읽고 screenshot request와 insert-text result route를 그려. 그다음 모든 window message listener를 audit해서 hardened version에 필요한 exact source, origin allowlist, type, requestId, payload check를 적어.
Hint
새 코드에서 lesson의 현재 '*'를 복제하지 마. 기록된 implementation debt야. 올바른 targetOrigin은 resolve된 frontend origin이고, 받는 event는 exact frame window에서 와야 해.

Progress

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

댓글 0

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

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