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

Sidekick 프레임

~13 min · iframe, postmessage, react, sidekick

Level 0밑준비
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

프레임은 가족 거, 안쪽 페이지는 Sizzle 거

가족 웹 앱은 다 킷의 Sidekick으로 자기 화면 옆에 피파를 둘 수 있어. iframe 하나를 감싼 패널이고, 옆에 붙거나 아래에 붙거나 떠 있어. 킷 헤더는 그 iframe을 앱용 cwkPippa 임베드 화면이라고 설명해. 아바타, 감정, 첨부까지 피파 전부가 들어 있는 화면이지. Sizzle은 그 테두리는 그대로 쓰고, iframe만 다른 데로 돌려. 자기 /talk?key=… 페이지로. 아키텍처 문서는 이걸 Easel 패턴이라고 불러. 바깥엔 가족 테두리, 안엔 Sizzle 대화 본문, 그 위엔 셰프 아바타와 지금 감정이 늘 보이게.

그냥 cwkPippa를 프레임에 넣으면 왜 안 되냐고? Sizzle 스레드는 채팅으로 끝나지 않거든. 피파는 답 끝에 제안을 붙일 수 있어. 읽기 고치기, 클래스 채택하기, 변형 만들기 같은 거. 그 제안은 Sizzle 라우트를 거쳐 Sizzle 기록에 들어가. 스레드 키도, 액션도, 그 버튼도 다 Sizzle 거니까, 그걸 그리는 페이지도 Sizzle 거여야 해. Sizzle 출처에서 나오고 Sizzle 엔진하고만 말하는 페이지. 브라우저는 cwkPippa 주소를 알 필요가 아예 없어.

대화만 그리는 경로 하나

라우터는 앉은 자리에서 다 읽을 만큼 작아. history.pushState랑 popstate 위에 훅 하나, 그리고 보조 키를 누른 클릭은 브라우저에 맡기는 Link 하나. 엔진은 API가 아닌 경로엔 전부 앱 셸로 답하니까, 새로 고침해도 같은 페이지에 떨어져. 경로는 일곱 개야. /, /p/<ULID>, /tree, /c/<ULID>, /kitchen, /settings, /talk.

App은 맨 마지막 경로부터 확인해. /talk이면 Talk 하나만 그려. 스레드 키는 쿼리 문자열에서 가져오고, 없으면 kitchen이야. 다른 경로는 전부 Shell을 받아. 헤더, 내비게이션, 두뇌 상태 칩, Sidekick 런처까지. 그래서 프레임 안에 헤더가 하나 더 뜨는 일이 없어.

프레임은 어느 스레드를 열까

셸은 지금 있는 페이지에서 스레드를 정해. 접시 페이지는 encounter:<ULID>를 열고 부제는 This plate(이 접시)야. 클래스 페이지는 class:<ULID>에 This class(이 클래스), 나머지 페이지는 전부 kitchen이고. Kitchen 페이지는 그 자체가 대화라서, Talk를 페이지 안에 바로 그리고 Sidekick이랑 런처는 숨겨. 엔진이 받는 키 모양은 딱 셋이야. kitchen, 아니면 encounter:나 class: 뒤에 26자 ULID.

아키텍처 문서는 이 묶음을 sizzle:encounter:<id>로 적어. 코드엔 그 대신 철자가 둘 있어. 경계 양쪽에 하나씩. Sizzle 안에선 키가 encounter:<ULID>야. cwkPippa 쪽으로 갈 땐 엔진이 앞에 sizzle_을 붙이고 콜론을 전부 밑줄로 바꿔서 클라이언트 id를 만들어. 그래서 대화 이름은 sizzle_encounter_<ULID>고, 출처 화면 sizzle로 만들어져. 가르칠 땐 코드가 쓰는 두 철자로 가르쳐. 문서에 적힌 모양은 코드 어디에도 없어.

프레임이 페이지에 알려줘

아빠가 프레임 안에서 액션을 적용하면 페이지 뒤의 기록이 바뀐 거야. 고친 요리 이름이나 새로 채택한 클래스는 손으로 새로 고치지 않아도 바로 보여야지. 그래서 Talk는 적용이 성공하면 자기가 프레임 안에 있는지 확인하고, 받는 출처를 자기 출처로 못 박아서 부모에게 sizzle:changed 타입 메시지를 보내. 셸은 메시지를 듣다가 출처가 페이지 자기 출처와 같고 타입이 맞을 때만 받아들이고, 새로 고침 카운터를 하나 올려. 접시, 클래스, 트리 페이지는 그 카운터를 prop으로 받아서 데이터를 다시 불러와.

프레임이 Sizzle 자기 페이지니까 양 끝은 처음부터 출처가 같아. 그래서 검사는 등호 하나로 끝나. 근데 그 등호가 거르는 건 출처지 창이 아니야. 다른 사이트에서 온 메시지는 무시되지만, 셸은 event.source를 자기 프레임과 비교하지 않아. 그래서 이 창을 손에 쥔 같은 출처의 창이 sizzle:changed를 보내도 카운터가 올라가. 여기선 엔진에서 한 번 더 다시 불러오는 걸로 끝나니까 손해는 없어. 새로 고침만 부탁하는 게 아니라 상태를 바꾸는 메시지였다면, 보낸 창까지 확인해야 해.

얼굴은 대화 위에 그대로

Talk는 턴보다 아바타를 먼저 그려. 기다리는 턴이 있으면 진지한 얼굴이 뜨고, 그 아래 줄엔 Pippa is thinking(피파가 생각 중)이 뜨고, 그 뒤에 매초 올라가는 타이머가 붙어. 그 밖엔 마지막으로 끝난 턴의 감정을 보여줘. 타이머는 ui.tsx에서 그 코드 옆에 적힌 가족 규칙을 따른 거야. 기다리는 화면은 얼마나 기다렸는지 보여줘야 한대. 그냥 도는 스피너로는 느린 답이랑 멈춘 답을 구분 못 하니까.

기다리는 턴이 있는 동안엔 스레드를 1.5초마다 다시 읽어. 보내기가 실패해도 요청 id는 그대로 들고 있어. 그래서 다시 보내면 엔진한테 같은 턴을 묻는 셈이 되고, 엔진은 턴을 새로 만들지 않고 그 재전송에 답해. 엔진 쪽에서 실패한 턴은 이유와 함께 Ask again(다시 묻기) 버튼을 보여줘.

폰엔 프레임이 없어

아이폰·아이패드 앱은 아무것도 끼워 넣지 않아. TalkView는 같은 스레드 라우트 위의 네이티브 SwiftUI야. 턴 위의 얼굴, 기다리는 동안 1.5초마다 다시 읽기, 실패한 보내기에서 요청 id 지키기까지 다 똑같아. iframe이 없으니 메시지도 없어. 대신 열린 화면에 보이는 게 바뀌었을 수 있을 때마다 앱 모델이 리비전 번호를 올려. 화면들은 그 번호를 보고 다시 불러오고.

웹에선 Sidekick이 열려 있었는지, 어떤 모드였는지를 로컬 저장소의 sizzle. 키로 기억해. 처음엔 화면 폭이 900픽셀 이상이면 옆 패널, 그보다 좁으면 아래 독이 기본이야. 킷의 프레임은 그 옆에 자기 크기 셋을 따로 둬. 옆 패널 폭, 아래 독 높이, 떠 있는 창 크기를 sizzle.pippa. 키로. 이 키를 읽고 쓰는 자리는 전부 예외를 잡아 두니까, 로컬 저장소가 거부하면 기본값이 그대로 서. 코드 주석은 그 경우를 사생활 보호 모드라고 불러. 쓰기마다 오류를 던지던 예전 Safari 사생활 보호 창엔 맞는 말이야. 요즘 사생활 보호 창은 보통 창이 열려 있는 동안엔 선택을 기억하고, 창을 닫으면 잊어.

원칙: 공용 프레임을 빌려 쓸 땐, 네 데이터를 쥔 내용은 네 출처에 둬. 프레임은 가족 거, 대화의 액션은 네 거, 둘을 잇는 다리는 한 줄로 검사할 수 있는 메시지 하나.

Code

cwkSizzle, frontend/src/App.tsx: Sizzle 자기 /talk 페이지를 가리키는 킷의 Sidekick (원문 그대로, 158-172행)·typescript
      <div className={`workspace sk-${open && showSidekick ? mode : "closed"}`}>
        <main className="page">{content}</main>
        {showSidekick && (
          <KitPippaSidekick
            open={open}
            mode={mode}
            storagePrefix="sizzle.pippa"
            embedUrl={`/talk?key=${encodeURIComponent(key)}`}
            appLabel="Sizzle"
            subtitle={subtitle}
            onModeChange={setMode}
            onClose={() => setOpen(false)}
            panelLayout="flow"
          />
        )}
cwkSizzle, frontend/src/Talk.tsx: 적용이 끝나면 프레임이 자기 출처를 받는 쪽으로 정해 부모에게 알려 (원문 그대로, 95-108행)·typescript
  const apply = async (turn: Turn, index: number) => {
    const key = `${turn.id}:${index}`;
    setApplying(key);
    try {
      const { thread: next } = await api.apply(turn.id, index);
      setThread(next);
      onChanged?.();
      if (window.parent !== window) window.parent.postMessage({ type: CHANGED_MESSAGE, key: threadKey }, location.origin);
    } catch (exc) {
      setError((exc as Error).message);
    } finally {
      setApplying(null);
    }
  };
cwkSizzle, frontend/src/App.tsx: 셸은 그 메시지를 자기 출처에서 온 것만 받고 다시 불러와 (원문 그대로, 86-94행)·typescript
  // An action applied inside the Sidekick frame changes the record the
  // page is showing; the frame says so and the page reloads its data.
  useEffect(() => {
    const onMessage = (event: MessageEvent) => {
      if (event.origin === location.origin && event.data?.type === CHANGED_MESSAGE) setRefresh((n) => n + 1);
    };
    window.addEventListener("message", onMessage);
    return () => window.removeEventListener("message", onMessage);
  }, []);

External links

Exercise

네 웹 앱에 패널 하나만 그리고 끝나는 경로를 만들어 봐. 예를 들면 화면에 뜬 기록에 달린 댓글 스레드 같은 거. 그 경로를 기록 페이지 안의 iframe에 넣어. 프레임 안의 액션이 기록을 바꾸면, 네 출처를 받는 쪽으로 정해서 부모에게 메시지를 보내. 부모는 다시 불러오기 전에 출처와 메시지 타입을 확인하게 하고. 마지막으로 패널이 서버를 기다리는 상태마다 경과 타이머가 붙은 대기 줄을 달아.
Hint
보내기 전에 창에 부모가 있는지 확인해. 그래야 같은 패널이 혼자서도 돌아가. 프레임을 페이지와 같은 출처에 두면 받는 쪽 검사는 등호 하나야. 다시 불러오기는 페이지들이 이미 받는 카운터 prop으로 돌려. 그러면 프레임과 페이지 사이에 공유 저장소가 필요 없어.

Progress

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

댓글 0

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

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