핵심 한 줄: 원격 호스팅 DevTools 프론트엔드(appspot.com)를 로컬 CDP에 붙이되, 중간에 로컬 프록시(9223)를 끼워 Chrome의 cross-origin WebSocket 거부를 우회한다.
연결 경로:
chrome_devtools_remote(unix socket) →adb forward로localhost:9222에 노출localhost:9222에서/json으로 탭 목록,/devtools/page/{id}로 페이지별 CDP WS 제공localhost:9223aiohttp 프록시가 프론트엔드 WS를 받아 9222로 양방향 중계- DevTools 프론트엔드는
termux-open-url로 브라우저에서 실행,?ws=localhost:9223/{id}로 백엔드 지정
트릭의 본질 — 왜 프록시가 필요한가:
- 프론트엔드 origin은
https://chrome-devtools-frontend.appspot.com→ 9222 직결 시 Chrome CDP가 외부 Origin WS를 거부 (DNS rebinding 방어) - 프록시가 프론트엔드 WS를 종단하고 파이썬 프로세스가 새 WS로 9222에 재접속 → 브라우저 Origin이 없으니 통과 (“Origin laundering”).
cors_middleware가 허용 헤더를 덧붙인다.
기억해둘 것:
ws://localhost이 HTTPS 페이지에서 mixed-content로 안 막히는 이유: localhost는 secure context로 취급 → IP/도메인이었으면wss강제로 깨졌을 것.- 더 깔끔한 대안: 프록시를 안 쓰고 프론트엔드를
localhost에서 self-host하면 Origin 문제 자체가 사라진다. ?@f84901f7…= DevTools 프론트엔드 빌드 리비전 핀.
필요한 만큼만 일한다 — lazy, pull-based. array 메서드(filter/map/slice)는 매 단계 중간 배열을 새로 할당하고(eager), 10개만 필요해도 전부 처리한다. Iterator Helpers는 iterator 위에 붙는 chainable 메서드로 같은 체인을 lazy하게 돈다.
async function* fetchPages() {
let page = 1
while (true) {
// 무한 루프지만 안전
const res = await fetch(`/api/items?page=${page++}`)
if (!res.ok) return
yield* await res.json()
}
}
const firstTen = await fetchPages().filter(isValid).take(10).toArray()
흐름은 소비자가 당기는 pull: toArray()가 값을 당기면 take→filter→fetchPages()로 거슬러 올라가 한 개씩 요청한다. filter 탈락이면 그 자리서 다음 값을 다시 당기고(중간 배열 없음), take(N)이 N개를 채우는 순간 iterator가 종료된다 → 아직 안 본 페이지는 fetch 요청 자체가 안 나간다. while (true)가 안전한 이유.
멘탈 모델: iterator는 이미 가진 데이터가 아니라 *아직 일어나지 않은 작업(work)*이다.
요점:
- iterator로 변환:
.values()/.keys()/.entries()또는 generator.slice(0,n)→take(n),slice(n)→drop(n). reduce는 전체를 봐야 해서 eager하게 소비(예외).- RxJS가 push(값이 밀려옴 · 이벤트/시간)라면 이쪽은 pull(소비자가 당김) → 구조적으로 backpressure가 걸린다.
함정:
- lazy 실행:
toArray()/for await전엔 side effect(fetch 포함)가 안 일어난다 → “왜 요청이 안 나가지?” - one-shot: 한 번 소비한 iterator는 재사용·random access(
items[5]) 불가.console.log로 찍으면 값이 소모돼 동작이 바뀐다.
모던 브라우저 + Node 22+.
- 출처: Matt Smith, “Stop turning everything into arrays (and do less work instead)” (2026.01)
싱글톤이 안티패턴이라는 통념은 싱글톤 자체가 아니라 React에 붙이는 방식이 후졌던 데서 나왔다. EventTarget 확장 + useSyncExternalStore면 프레임워크 무관 코어를 stale 없이 연결한다. 코어(표준 JS 클래스, React를 모름) / 어댑터(useSyncExternalStore 한 겹)로 분리 — Vue·Svelte는 어댑터만 갈아끼운다.
연결 방식의 진화:
- polling / 수동 refresh — stale, 데이터 변경과 무관한 리렌더
useEffect+addEventListener— 낫지만 로컬useState를 손으로 동기화 (React state스럽지 않음)useSyncExternalStore— 구독·동기화를 React가 직접 관리, stale closure 제거
const subscribe = (cb: () => void) => {
const ac = new AbortController()
toastManager.addEventListener('changed', cb, { signal: ac.signal })
return () => ac.abort()
}
const getSnapshot = () => toastManager.toasts
const toasts = useSyncExternalStore(subscribe, getSnapshot)
코어의 핵심은 setter 하나 — 상태 변경과 이벤트 발행을 원자적으로 묶는다:
private set toasts(value: Toast[]) {
this._toasts = [...value] // 새 배열 → 참조 동등성으로 변화 감지
this.dispatchEvent('changed') // 변경 시 발행 누락 불가능
}
함정: getSnapshot은 참조가 안정적이어야 한다. 매번 새 객체를 리턴하면 무한 루프 — getter는 내부 배열을 그대로 반환하고, 새 배열은 setter에서만 만든다.
같은 계열 — 프레임워크 밖에 source of truth를 두고 UI는 구독만 하는 사고방식: build-time swap(562), IndexedDB-first(561).
코어는 createSignal·batch·untrack·createId 4개를 이름으로만 import하고 구현은 절대 고르지 않는다. 번들러가 타깃별 sibling(index.solid.ts … index.qwik.ts)으로 redirect → 런타임엔 각 프레임워크의 네이티브 signal이 그대로 들어가 추상화 세금이 0.
핵심은 런타임 DI vs 빌드 타임 DI. 런타임 어댑터는 간접층이 배포물에 잔존해 인라이닝·tree-shaking을 막는다. 빌드 타임 swap은 seam이 출력물에서 증발한다 — createSignal이 문자 그대로 shallowRef가 된다. (monomorphization vs virtual dispatch, #ifdef vs 런타임 if)
가능했던 이유: 계약을 Signal<T> = get value / set value 단 둘로 좁힌 것(narrowest interface). .value shape로 모인 Vue/Preact/Qwik은 re-export 한 줄로 충족된다.
날카로운 반례 — React만 예외. 네이티브 signal이 없어 ~100줄 pub/sub을 직접 구현한다. 빌드 swap이 어댑터를 없앤 게 아니라, 런타임 방식이었다면 모두가 졌을 비용을 React 번들 하나에 가둔 것.
대가: universal 번들 불가(타깃당 아티팩트 N개), 코어는 4-함수 계약 밖의 reactivity를 못 쓴다. 계약을 좁게 유지할 수 있느냐 = 패턴 성립 여부.
네트워크를 가정하고 캐시를 끼워맞추지 말고, 처음부터 로컬을 본진으로. UI는 항상 로컬(IndexedDB)을 보고, 서버는 동기화 대상으로 내려간다.
캐시는 “네트워크가 1순위”라는 전제 위에 덧대는 임시방편 — 무효화·로딩 상태·페이지네이션이 전부 거기서 파생된다. 로컬을 본진으로 뒤집으면 그 파생 문제들이 통째로 사라지고 “동기화” 하나로 응축된다.
신선했던 지점은 이게 all-or-nothing이 아니라 난이도가 단계적으로 점프한다는 것:
- 읽기 전용 = 재다운로드만. 충돌 없음. 지금 당장 가능.
- 쓰기 = optimistic + outbox로 대부분.
- 사용자별 분산 = 여기서부터 분산 시스템(Git 모델).
→ 읽기 전용 앱이면 오늘 시작할 수 있다.
재귀(콜 스택) 대신 child / sibling / return 세 포인터로 트리를 순회한다 → 스택 없이 DFS pre-order, 언제든 중단·재개 가능. Fiber가 렌더링을 쪼갤 수 있는 이유.
재귀로 짜면 진행 상태가 JS 콜 스택에 쌓이는데, requestIdleCallback으로 도중에 yield하면 그 스택을 버리게 된다. 재개하려면 스택을 다시 쌓아야 해서 “어디까지 했는지”를 외부에서 들고 있어야 한다.
그 상태를 workInProgress 포인터 하나로 환원한다. 노드마다 걸린 세 링크를 직접 따라가므로 호출 스택이 필요 없다.
1. child 있으면? → 내려간다
2. root에 도달했으면? → 종료
3. sibling 없으면? → return(부모)으로 올라가며 반복
4. sibling 있으면? → 옆으로 간다
내려가는 길 = beginWork, 올라오는 길 = completeWork. 한 노드를 두 번(하강·상승) 지나는 흐름이 그대로 두 단계로 갈린다.
queueMicrotask·Promise.then이 setTimeout(0)·동기 코드와 얽히는 걸 한 스텝씩 보는 시각화. 핵심: 마이크로태스크 큐는 매 턴 끝까지 비워진다 → then/queueMicrotask는 항상 setTimeout(0)보다 먼저고, 드레인 도중 추가된 중첩 마이크로태스크까지 같은 턴에 처리된다(중첩이 유한한 한 starvation 없음).
한 이벤트 루프 턴 = 네 단계:
- task queue에서 가장 오래된 매크로태스크 1개 실행
- microtask checkpoint — 큐가 빌 때까지 전부 드레인 (드레인 중 추가된 것 포함)
- 렌더링 갱신 (필요 시)
- 1로 복귀
그래서 한 턴의 트레이스 = [micro들] ++ [macro 하나] — 매크로는 맨 끝, 드레인 도중 끼어들 수 없다.
knip은 “이 심볼을 누가 import하나?“를 심볼마다 거꾸로 묻지 않는다. 모듈 그래프를 한 번 만들고, entry에서 import를 따라 도달 가능한 집합 R을 구한 뒤, 그 여집합(전체 \ R)을 미사용으로 본다. (심볼별 역참조 질의 O(|심볼|·|모듈|) → 그래프 1회 구축 + 도달 1회 + 집합 차)
그래서 dead의 정체는 아무도 안 쓰는 코드가 아니라 entry에서 도달 불가한 코드다 — 자기가 남을 import해도, 자신이 (간접적으로라도) entry에서 import되지 않으면 dead.
entry 선언이 곧 판정의 기준점이다. entry 하나를 빠뜨리면 R이 줄고 그 아래 멀쩡한 서브트리가 통째로 거짓 미사용으로 잡힌다 — config·플러그인·동적 import 진입점을 knip에 꼭 등록해야 하는 이유.
explorer: 노드를 누르면 entry→그 노드 도달 경로가 펼쳐지고, plugin entry를 끄면 api로 가는 유일 경로가 끊겨 빨갛게 거짓 미사용으로 뒤집힌다.
Navigation API에서 traverse(뒤/앞 이동)만 preventDefault()로 막을 수 없다. push·replace·reload는 취소 가능, traverse는 취소 정의역 밖.
그래서 traverse blocker는 사전 차단이 아니라 사후 롤백이다 — 이동은 이미 일어났으니 되돌린다:
// traverse가 이미 go(n)을 일으킴 → blocker는 go(-n)으로 원위치
onNavigate(e) {
if (blocked) history.go(-delta) // 관측상 항등 ("리스트 + 커서" 모델이라)
}
정의역 조건(0 ≤ idx + n) 안에서만 항등. 형식 닻: go_rollback_id — go n 다음 go (-n) = h.
곁가지 (같은 list+cursor 모델의 다른 비대칭):
- navigationType에서
reload·traverse가 같은 POP으로 뭉개짐(정보 손실) → idx 변화량delta로 구분. - 내부 호출이 쏜 navigate 이벤트는
info태그로 흡수 → 재진입 종료.
Native Messaging — 확장이 로컬 바이너리를 실행하는 법
Native Messaging = “샌드박스는 유지하되, 등록된 외부 프로세스 하나와 stdio로만 대화한다”. Chrome이 호스트를 별도 자식 프로세스로 spawn하고, 그 프로세스가 yt-dlp를 실행한다.
통신은 stdio 위 길이접두 JSON — 소켓/HTTP 아님. 메시지마다 4바이트 길이 헤더(네이티브 바이트 순서) + UTF-8 JSON.
raw_length = sys.stdin.buffer.read(4)
message_length = struct.unpack('<I', raw_length)[0] # 길이 먼저
message = sys.stdin.buffer.read(message_length) # 그만큼 본문
→ 호스트는 stdout에 print 금지(곧 프로토콜 깨짐). 로그는 stderr로.
content script는 통로 밖 — sendNativeMessage/connectNative는 background(service worker)에서만 호출 가능. 그래서 흐름이 content.js(ID 추출) → background.js(네이티브 통신)로 갈라진다.
프로세스 수명은 호출 방식이 결정 — sendNativeMessage는 단발(첫 응답 후 종료, “ID 던지고 끝”에 적합), connectNative는 Port 유지(진행률 스트리밍).
macOS PATH 함정 — GUI Chrome은 .zshrc를 안 읽는다. 호스트가 독립 프로세스라 사용자 셸 환경을 미상속 → yt-dlp는 절대경로로.