"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
- Iframe이 bridge 한테 context 요청 — iframe이
window.parent.postMessage({type:'pippa:request-context', requestId}, '*'). bridge의 message listener가 잡고, SW 한테 묻고, 결과를 iframe 으로 다시 post. - SW가 panel 에 context push — content script가 새 context 보고할 때, SW가
chrome.runtime.sendMessage통해 broadcast. bridge의 chrome.runtime.onMessage listener가 잡고 iframe 에 post. - Iframe boot — iframe load와 bridge load 시 bridge가 requestContext() 호출해서 SW가 이미 가진 context로 iframe seed.
- 나중엔 양방향으로 — 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.