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/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"열 feature 에 한 번쯤 필요할 거. 근데 필요할 땐 아무리 영리한 ISOLATED-world DOM access 도 대체 못 함. Lesson 5 는 content script 와 page 가 대화하는 네 가지 정당한 방법, 그리고 언제 어느 걸 골라야 하는지."

실제로 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 read, DOM write, event listening, storage round-trip — 은 bridge 필요 없음. ISOLATED 가 정말 질문에 답 못 할 때만 bridge 손 뻗어.

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 messaging API. 두 world 가 같은 window object (JS global 이 아닌 DOM window) 공유해서, 거기 message post 하고 listen 가능. Message payload 는 structured-clone, plain data 가 그대로 생존.

  • 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 — world: 'MAIN' 의 chrome.scripting (Modern)

Lesson 3 가 이미 소개. SW 가 chrome.scripting.executeScript({ target, world: 'MAIN', func }) 부르면 함수가 page JS world 에서 직접 돌고 결과 return. Script tag 없음, web_accessible_resources 없음, relay 코드 없음.

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 설정.

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 = Math.random().toString(36).slice(2);
    function onResponse(event) {
      if (event.source !== window) 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 }, "*");
    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

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

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