CrossChat

CrossChat — Architecture Design Document

Working name: CrossChat. A browser extension that replaces YouTube’s live chat with a chat panel sourced from another streaming platform (Twitch first, Kick later), rendered with full third-party emote support (7TV, BTTV, FFZ) so the viewing experience matches what native Twitch/Kick users see.

Status: Implemented for Twitch — anonymous read, 7TV/BTTV/FFZ emotes, and authenticated send. Kick and YouTube-as-target are deferred but interface-ready. This document describes the architecture as built and is the reference for how the application is structured. Last updated: 2026-06-29


1. Motivation & Problem Statement

An existing extension (“Twitch Chat for YouTube”) embeds Twitch chat onto YouTube live pages by injecting an <iframe src="https://www.twitch.tv/embed/.../chat"> into YouTube’s ytd-live-chat-frame container. This works, but the iframe is Twitch’s own cross-origin document, and locally-installed emote extensions (7TV, BTTV, FFZ) do not inject into it. The result: the user sees raw emote text while everyone else sees emote images.

This cannot be patched onto the iframe approach — it is structural. The fix is to stop embedding Twitch’s chat UI and render our own chat panel, over which we have full control of message rendering and can therefore composite in third-party emotes ourselves.

Goals (v1)

Non-Goals (v1)

Deferred / Future


2. Key Design Decisions (and rationale)

Decision Rationale
Render our own chat panel instead of embedding an iframe The iframe is the root cause of the emote gap. Self-rendering is the only way to composite 7TV/BTTV/FFZ.
Source/Sink abstraction from day one Lets Twitch be v1 while keeping Kick (and a YouTube target) addable without rewrites. Designing against the shape of two APIs keeps the abstraction from being secretly Twitch-shaped.
Anonymous IRC for read, OAuth only for send Reading should need no account — most viewers lurk. Anonymous IRC-over-WebSocket delivers full chat with zero auth, so the login cost falls only on users who actually want to talk. Behind the ChatSource/ChatSink interfaces, an authenticated EventSub read can replace IRC later without touching the UI.
Sockets and tokens owned by the background service worker Content scripts are the wrong place for a long-lived socket or an access token. The worker owns connection lifecycle, identity and tokens; the content script is a thin renderer.
Twitch first, Kick second, YouTube-source last/never Ranked purely by integration friendliness. Twitch wants to be read; Kick has an official API; YouTube read is polling + quota-capped and doesn’t scale to a published extension.
Markdown design doc in-repo Versioned with code, directly readable by Claude Code.

3. High-Level Architecture

┌─────────────────────────────────────────────────────────────────┐
│ YouTube live page (content script context)                       │
│                                                                   │
│  ┌────────────────────┐      injects/sizes      ┌──────────────┐ │
│  │ YouTube DOM Adapter │ ───────────────────────▶│ Chat Panel UI │ │
│  │  - channel handle   │                          │  - message    │ │
│  │  - chat container   │                          │    list       │ │
│  │  - video height     │                          │  - input box  │ │
│  │  - dark mode        │                          │  - emote      │ │
│  └────────────────────┘                          │    picker     │ │
│            │                                       └──────┬───────┘ │
│            │   chrome.runtime messaging (port)            │         │
└────────────┼───────────────────────────────────────────-─┼─────────┘
             │                                              │
┌────────────▼──────────────────────────────────────────---▼─────────┐
│ Background Service Worker                                           │
│                                                                    │
│  ┌──────────────┐   ┌─────────────────┐   ┌─────────────────────┐  │
│  │ Auth Manager │   │  Chat Engine     │   │ Emote Registry      │  │
│  │ - OAuth flow │   │  (Source/Sink)   │   │ - Twitch native     │  │
│  │ - token store│──▶│  - TwitchSource  │◀──│ - 7TV  (global+chan)│  │
│  │ - implicit   │   │  - TwitchSink    │   │ - BTTV (global+chan)│  │
│  └──────────────┘   │  - [KickSource]  │   │ - FFZ  (global+chan)│  │
│                     │  - [KickSink]    │   │ merged name→img map │  │
│                     └─────────────────┘   └─────────────────────┘  │
└────────────────────────────────────────────────────────────────────┘

The content script never holds tokens or sockets. It renders and forwards user input. The worker resolves identities, owns the live connection, normalizes inbound messages, applies the emote registry, and pushes render-ready message objects to the panel.


4. Core Abstractions

4.1 ChatSource (read)

A platform-agnostic inbound stream of normalized messages.

interface ChatSource {
  platform: "twitch" | "kick";          // (youtube deferred)
  connect(channel: ResolvedChannel): Promise<void>;
  disconnect(): void;
  onMessage(cb: (msg: NormalizedMessage) => void): void;
  onState(cb: (s: ConnectionState) => void): void;
}

4.2 ChatSink (send)

A platform-agnostic outbound path.

interface ChatSink {
  platform: "twitch" | "kick" | "youtube";  // youtube allowed as TARGET only
  send(channel: ResolvedChannel, text: string): Promise<SendResult>;
  capabilities(): { canSend: boolean; rateLimitHint?: number };
}

4.3 NormalizedMessage

The single internal shape every source must produce. Rendering and emote compositing operate only on this, never on platform-specific payloads.

interface NormalizedMessage {
  id: string;
  author: { displayName: string; color?: string; badges: Badge[] };
  fragments: Fragment[];   // text + platform-native emotes, pre-split by the source
  timestamp: number;
  platform: string;
}

type Fragment =
  | { type: "text"; value: string }
  | { type: "emote"; name: string; url: string; provider: "twitch"|"7tv"|"bttv"|"ffz" };

Important: the source is responsible only for splitting out platform-native emotes (e.g. Twitch emotes, which arrive identified in the event payload). The Emote Registry (§5) does a second pass over remaining text fragments to substitute 7TV/BTTV/FFZ emotes, since those are client-side overlays not present in the wire payload.

4.4 ResolvedChannel

interface ResolvedChannel {
  platform: string;
  login: string;        // e.g. twitch username
  userId: string;       // numeric/string platform ID — needed for emote sets (and send/EventSub)
  displayName?: string;
}

5. Emote Registry

The differentiator. Builds a merged name → emote lookup per channel.

Sources of emotes

  1. Twitch native — already identified in the wire payload (the IRCv3 emotes tag today, the EventSub payload later); the source emits them as emote fragments directly. No registry lookup needed.
  2. 7TV — global set + per-channel set (keyed by the platform user ID). First-class Kick support too, which is a point in Kick’s favor later.
  3. BTTV — global + per-channel.
  4. FFZ — global + per-channel.

Build process (per channel connect)

  1. Resolve userId for the channel with no auth via FFZ’s room endpoint (Helix /users is used only on the send path, where a token is available). An empty userId still allows IRC read; only the per-channel emote sets fail to load.
  2. Fetch global sets for 7TV / BTTV / FFZ (cacheable across channels, refresh periodically).
  3. Fetch per-channel sets for each provider using userId.
  4. Merge into one Map<emoteName, {url, provider}>. Define precedence on name collisions (suggested: channel-specific over global; provider order configurable, default 7TV > BTTV > FFZ).

Apply process (per message)

For each text fragment in a NormalizedMessage, tokenize on whitespace, and for each token that exactly matches (case-sensitive) a registry key, replace it with an emote fragment. Emotes are whole-word matches — do not substitute inside larger words.

Note: exact, case-sensitive whole-word matching mirrors how the real extensions behave and how Twitch itself treats emote names on send.

Caching


6. Platform Modules

6.1 Twitch (anonymous read + authenticated send)

NOTE for the implementer: the anonymous IRC read, FFZ-based user-id resolution and anonymous GraphQL badges are deliberate no-auth shortcuts; verify their (undocumented, churn-prone) shapes against current behavior when touching them. They sit behind the source/sink/registry interfaces so an official EventSub/Helix read can drop in later without reworking the engine or panel.

6.2 Kick (deferred — second platform)

6.3 YouTube


7. YouTube DOM Adapter (reused knowledge)

The original extension already solved the YouTube-side glue; port these specifics:

SPA caveat: YouTube is a single-page app; navigations don’t reload the content script. The adapter must observe URL/navigation changes (e.g. yt-navigate-finish event or a MutationObserver) and re-inject/reconnect when the user moves between streams.


8. Extension Structure (Manifest V3)

crosschat/
├── manifest.json            # MV3; permissions, host permissions, OAuth redirect
├── src/
│   ├── content/
│   │   ├── youtube-adapter.ts   # DOM detection, injection, height/dark-mode, SPA nav
│   │   └── panel/               # chat panel UI (message list, input, emote picker)
│   ├── background/
│   │   ├── service-worker.ts    # entry; message routing between panel and engine
│   │   ├── auth/                # OAuth flows + token storage/refresh (per platform)
│   │   ├── engine/              # ChatSource/ChatSink orchestration, normalization
│   │   ├── sources/             # twitch-source.ts  [kick-source.ts]
│   │   ├── sinks/               # twitch-sink.ts     [kick-sink.ts] [youtube-sink.ts]
│   │   └── emotes/              # registry, provider fetchers (7tv/bttv/ffz), cache
│   ├── shared/                  # NormalizedMessage, interfaces, types, constants
│   └── ui/                      # shared components/styling
├── ARCHITECTURE.md          # this document
└── README.md

Permissions


9. Message Flow (end to end)

Inbound (read + render):

  1. Adapter detects channel handle → asks worker to resolve source channel.
  2. Worker maps YouTube handle → Twitch login (storage/prompt), resolves userId via FFZ.
  3. Worker builds Emote Registry for the channel (cached where possible).
  4. Worker connects TwitchSource (anonymous IRC) and JOINs the channel.
  5. On each PRIVMSG: source emits NormalizedMessage with native-emote fragments split out.
  6. Emote Registry second pass substitutes 7TV/BTTV/FFZ tokens in text fragments.
  7. Worker pushes render-ready message to the panel; panel appends to the list.

Outbound (send):

  1. User types in the panel composer (plain text today; an emote picker/autocomplete over the registry is a future addition).
  2. Panel sends raw text to worker.
  3. Worker calls TwitchSink.send() → Helix POST /chat/messages with the user’s token.
  4. Message echoes back through the inbound IRC read path and renders like any other.

10. Risks & Open Questions


11. Build Order

The order the layers were built (and the order a fresh platform should follow):

  1. MV3 skeleton + YouTube DOM Adapter: detect channel, inject an empty panel, sync height and dark mode, handle SPA navigation. (Pure front-end, no auth — fastest path to “it’s on the page”.)
  2. Twitch read (anonymous): stand up the ChatSource interface and the anonymous IRC source; get messages rendering as plain text first. This is kept as the read path, not a throwaway — login-free reading is a goal (§2), and the interface lets an EventSub read replace it later.
  3. Emote Registry: add 7TV/BTTV/FFZ fetch + merge + the per-message substitution pass. This is the payoff milestone — emotes start rendering.
  4. OAuth (Auth Manager): Twitch app registration (public client), implicit-grant flow via chrome.identity, token storage + validation. No refresh token, no client secret.
  5. Send (Sink): add the composer and TwitchSink over Helix POST /chat/messages; reads stay on IRC and sent messages echo back through it.
  6. Polish: username mapping/persistence, reconnection, caching TTLs, error states.
  7. (Later) Kick module: second source/sink against the same interfaces.

Future additions behind the existing interfaces: authenticated EventSub read, an emote picker/autocomplete in the composer, and a YouTube send target.

Implement behind the Source/Sink/Registry interfaces throughout so that step 7 — and any future YouTube target — is additive rather than invasive.