"기능 열 개 만들면 한 번쯤 필요해질 거야. 그런데 정작 필요해지면, 격리된 세계에서 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 (보통
document나window)에서 dispatch. - Payload는
detail에, 단 caveat:detailobject는 공유되지만 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에 코드 넣는 경로:
- Extension 안 별도 파일로
injected.js작성. - Page가 load 허락받도록 manifest의
web_accessible_resources에 list. - Content script에서
<script src="chrome-extension://.../injected.js">만들어document.documentElement에 append. - 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 검증.
'*' 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 === window와 event.origin === location.origin을 둘 다 확인해. 고정 sentinel/type/schema를 검증하고 request ID는 crypto.randomUUID()로 만들어. 같은 window의 page code도 메시지를 볼 수 있으니 secret은 절대 bridge하지 마.