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

Page bridge — Host의 JavaScript world와 대화

~12 min · page-bridge, postMessage, custom-event, web-accessible-resources, world-main

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"기능 열 개 만들면 한 번쯤 필요해질 거야. 그런데 정작 필요해지면, 격리된 세계에서 DOM을 아무리 영리하게 주물러도 이걸 대신할 수가 없어. 이 lesson은 content script와 페이지가 대화하는 정당한 방법 네 가지, 그리고 언제 어느 걸 골라야 하는지에 대한 얘기야."

실제로 bridge가 필요할 때

대부분의 ClipDeck feature (DOM read, selection read, SW로 ship)가 ISOLATED world 안에 완전히 살아. Bridge는 이런 때만 필요:

  • Page 정의 global 읽어야 할 때 — window.React.version, window.__INITIAL_STATE__, CMS의 data-layer object.
  • Page 정의 함수 불러야 할 때 — "사이트 자체 검색 trigger", "page의 analytics queue에 payload 건네기".
  • Framework runtime hook과 통합할 때 (React DevTools-style 작업).

그 밖의 일은 — DOM을 읽든, 고치든, event를 듣든, storage를 오가든 — 다리가 필요 없어. 격리된 세계 안에서는 정말로 답이 안 나오는 질문일 때만 다리로 손을 뻗어.

Pattern A — CustomEvent

가장 단순. 한 world가 dispatch, 다른 쪽이 listen. 양방향 동작.

  • DOM node (보통 documentwindow)에서 dispatch.
  • Payload는 detail에, 단 caveat: detail object는 공유되지만 primitive 보다 풍부한 건 world 간 wrap/unwrap 될 수 있음 — JSON-clonable shape으로 유지.
  • One-shot signaling: "user가 방금 X 했음", "sidebar refresh해 줘".

Pattern B — window.postMessage

DOM 쪽 메시지 API 야. 두 세계가 같은 window를 같이 보거든 (JS 전역이 아니라 DOM의 window 말이야). 그래서 거기다 메시지를 던지고 받을 수 있어. 실려 가는 값은 복제돼서 건너가니까 평범한 데이터는 그대로 살아남고.

  • Request/response가 자연: unique id와 request post, 같은 id의 response listen.
  • Iframe 경계도 cross (explicit origin check와 함께).
  • Sentinel 패턴 필수 — page가 뭐든 post 가능하니, payload 신뢰 전에 known marker field로 filter.

Pattern C — Injected Script Tag (Legacy)

Pre-Chrome-111 시절, page JS world에 코드 넣는 경로:

  1. Extension 안 별도 파일로 injected.js 작성.
  2. Page가 load 허락받도록 manifest의 web_accessible_resources에 list.
  3. Content script에서 <script src="chrome-extension://.../injected.js"> 만들어 document.documentElement에 append.
  4. Script tag가 page world에서 실행. CustomEvent 나 postMessage로 다시 통신.

현대 Chrome 에서도 동작. world: 'MAIN'으로 migrate 안 한 declarative content script에서 MAIN-world 실행 필요할 때 유용.

Pattern D — chrome.scripting, world: 'MAIN' (Modern)

Lesson 3에서 이미 만났지. SW가 chrome.scripting.executeScript({ target, world: 'MAIN', func })를 부르면 그 함수가 페이지 JS 세계에서 바로 돌고 결과를 돌려줘. script 태그를 심을 것도, web_accessible_resources를 적을 것도, 중간에서 나르는 코드를 짤 것도 없어.

Extension event (toolbar click, context menu, popup 메시지)가 trigger 하는 one-shot MAIN-world 호출의 가장 깔끔한 경로. Declarative로 inject 하면서 MAIN 필요할 때는 Chrome 111+ 가 content_scripts manifest entry의 "world": "MAIN"도 허용.

Sentinel과 origin — 두 가지 규율

임의의 page에서 메시지 listen 하는 모든 것은 guard 필요:

  • Sentinel field. 보내는 모든 메시지가 { source: "clipdeck-content" } 같은 거 휴대. 모든 listener가 그 field 먼저 확인. 악성 page가 같은 sentinel 가진 가짜 메시지를 post 할 있지만, 표준 관행이 다른 library 와의 우발적 cross-talk 대부분 막아.
  • Origin check. window.postMessage 에는 event.source === window (메시지가 iframe이 아닌 이 같은 window에서 옴)와 관련될 때 event.origin이 예상 URL 매칭하는지 검증.
  • MAIN-world 메시지를 untrusted로 다루기. 데이터가 MAIN에서 ISOLATED로 건너오면, page가 영향 줄 수 있는 곳에서 온 거. 저장 전 타입 / 길이 / shape 검증.
거의 모든 것에 ISOLATED. Page의 실제 JavaScript 필요한 드문 순간에 MAIN. 문제 푸는 가장 단순한 bridge 고르기 — signal에 CustomEvent, request/response에 postMessage, one-shot read에 world MAIN의 chrome.scripting.
'*' targetOrigin 함정. window.postMessage(payload, '*')은 origin 무관 모든 listener에 메시지 보냄. Dev 중엔 편하지만 production 엔 위험 — 같은 window에 message listener 등록한 third-party script가 payload 다 봄. 한 tab 안 ISOLATED↔MAIN 엔 '*' 가 받아들일 만 — same-tab 경계가 이미 외부 site와 isolate. cross-frame messaging 엔 의도한 specific target origin 설정.

Bridge 규칙. 양쪽이 같은 page에 있으면 location.origin으로 보내고, event.source === windowevent.origin === location.origin을 둘 다 확인해. 고정 sentinel/type/schema를 검증하고 request ID는 crypto.randomUUID()로 만들어. 같은 window의 page code도 메시지를 볼 수 있으니 secret은 절대 bridge하지 마.

Code

CustomEvent listener — content script가 page-dispatch 신호 잡기·javascript
// content.js (ISOLATED) — page 가 dispatch 한 CustomEvent listen
document.addEventListener("clipdeck:react-version", (event) => {
  const version = event.detail?.version;
  console.log("[ClipDeck content] page reported React version:", version);
  chrome.runtime.sendMessage({ type: "reactVersion", version });
});

// Page (MAIN world) 쪽에서는:
// document.dispatchEvent(
//   new CustomEvent('clipdeck:react-version', { detail: { version: React.version } })
// );
// Pattern D 의 inject 된 MAIN-world 스크립트가 이걸 제공.
Sentinel + id 가진 window.postMessage의 Promise-wrap 된 request/response·javascript
// content.js (ISOLATED) — window.postMessage 로 request/response
function askPage(type, payload) {
  return new Promise((resolve, reject) => {
    const id = crypto.randomUUID();
    function onResponse(event) {
      if (event.source !== window || event.origin !== location.origin) return;
      const data = event.data;
      if (!data || data.source !== "clipdeck-page" || data.id !== id) return;
      window.removeEventListener("message", onResponse);
      if (data.error) reject(new Error(data.error));
      else resolve(data.payload);
    }
    window.addEventListener("message", onResponse);
    window.postMessage({ source: "clipdeck-content", id, type, payload }, location.origin);
    setTimeout(() => {
      window.removeEventListener("message", onResponse);
      reject(new Error("timeout"));
    }, 2000);
  });
}

// Usage (MAIN-world helper 가 자리잡은 후):
// const version = await askPage('getReactVersion');
MAIN-world one-shot — window.React.version 깔끔하게 read·javascript
// background.js — Pattern D, 현대적 MAIN-world one-shot
async function readPageReactVersion(tabId) {
  const [result] = await chrome.scripting.executeScript({
    target: { tabId },
    world: "MAIN",
    func: () => {
      try {
        // Page 자체 React reference. 많은 site 가 attach.
        return window.React?.version ?? null;
      } catch (err) {
        return null;
      }
    },
  });
  return result?.result ?? null;
}

// Caller (예: popup 에서 온 onMessage 안):
// const version = await readPageReactVersion(tab.id);

External links

Exercise

세 번째 code block (readPageReactVersion)을 clipdeck/background.js에 추가하고 message handler에 wire: SW가 popup 으로부터 {type:'getReactVersion'} 받으면, readPageReactVersion(sender.tab?.id || (await chrome.tabs.query({active:true,currentWindow:true}))[0].id) 호출해서 version으로 응답. clipdeck/popup.html에 message 보내는 Detect React 버튼 추가. https://react.dev (version 반환 가능)와 https://wikipedia.org (null 반환 가능)에서 테스트. 흥미로운 순간은 React 쓰는 거 아는데 window.React 안 노출하는 site에서 테스트 — 대부분의 production React 앱이 React를 bundle 하되 global에 할당 안 함, 그래서 결과 null. 현실: MAIN-world access가 거기 있는 걸 보여 주지, 있어야 할 걸 안 보여 줘.
Hint
executeScriptCannot access contents of the page 에러 내면 URL이 Chrome의 restricted 한 (chrome://, Chrome Web Store 자체 등) 거. 실제 http/https page로 먼저 이동. React 분명히 도는 page 인데 popup이 null 보이면, 그 page가 window에 attach 안 하고 React bundle — 대신 window.__REACT_DEVTOOLS_GLOBAL_HOOK__ 확인 시도, 대부분의 bundled React 앱이 그건 설정. 나중에 더 풍부한 page-side probing 원하면, React fiber tree walk 하는 작은 MAIN-world helper 작성하고 postMessage로 다시 보고 — Pattern B의 request/response shape이 그걸 깔끔하게 처리.

Progress

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

댓글 0

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

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