Files
2026-09-05 20:39:44 +05:00

29 KiB
Raw Permalink Blame History

Исследование: Twitch Panel Extension «мультидокументная панель»

Задача: расширение размером в одну Twitch-панель (в блоке «О канале»), внутри которого листается несколько документов на основе Markdown (+ заголовочные картинки). Управление контентом — через удобную админ-панель; желательно обновление через git. Фронтенд — Svelte 5.


1. Вердикт

Полностью реализуемо, без бэкенда, бесплатно. Платформа Twitch Extensions жива (20252026, Developer Rig устарел, но сами Extensions и ревью работают). Ключевой трюк: код приложения (зашит в билд расширения) почти не меняется, а контент (MD + картинки) тянется в рантайме из git-репозитория через CORS-дружелюбный CDN (jsDelivr / raw.githubusercontent.com — оба отдают Access-Control-Allow-Origin: *, проверено curl-ом). Обновление контента = git push, без пересборки и без ревью Twitch.


2. Ключевые факты о панельных расширениях (проверено по докам)

Факт Значение
Тип расширения Panel — «коробка» в блоке «О канале», под плеером
Размер 318 × 496 px, жёстко (чтобы не было скролла iframe); скроллим внутренний контент
Активность Панель видна даже когда канал офлайн — идеально для био/справки
Pop-out Зритель может открыть расширение в отдельном окне побольше
Хостинг кода zip-загрузка на CDN Twitch (*.ext-twitch.tv) ИЛИ свой HTTPS-хостинг (Base URI)
Config Service 3 сегмента (developer / broadcaster / global), 5 КБ на сегмент, публично читаемо
Helper-библиотека <script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"> — обязательна
CSP Настраивается в консоли: домены для connect-src и img-src надо внести в allowlist
Sandbox нет alert/confirm/prompt, нет вложенных iframe, нет top-navigation, storage API ограничен
Мобилки Панели показываются в мобильном приложении, если включить mobile-ready; бюджет первой загрузки 1 МБ / 3 сек
Ревью Нужно только для публичного релиза. На свой канал ставится без ревью (Local Test → Activate)
Жизненный цикл Local Test → Hosted Test → Review → Released (Released не редактируется, только новая версия)

Важно: в Config Service можно хранить только метаданные (список документов, порядок, URL'ы), не сами документы — 5 КБ мало, и сегмент публичен (никаких секретов). Контент живёт в git-репозитории.


3. Архитектура (рекомендуемый вариант, без бэкенда)

GitHub-репозиторий (public)                Twitch
├─ docs/
│  ├─ index.json      ← манифест контента   ┌─────────────────────────────┐
│  ├─ rules.md        ← сами документы      │ Extension (Panel 318×496)   │
│  ├─ faq.md                                │  Svelte 5 + Vite            │
│  └─ assets/                                │  ┌───────────────────────┐  │
│     ├─ rules-header.png                    │  │ табы/пейджер доков    │  │
│     └─ ...                                 │  │ MD → HTML (sanitized) │  │
└─ (git push = обновление контента)          │  │ картинки из CDN git'а │  │
                                             │  └───────────────────────┘  │
              Config Service (5 КБ)          │  Config view (в дашборде):  │
   broadcaster segment ← JSON: список доков, │  CRUD документов, порядок,  │
   порядок, заголовки, URL md/картинок       │  сохранение в конфиг-сегмент│
                                             └─────────────────────────────┘

Поток данных во вьювер-вью:

  1. При старте Helper отдаёт configuration.broadcaster (JSON со списком документов).
  2. Приложение рендерит табы/карусель; по выбору таба лениво фетчит MD с jsDelivr (https://cdn.jsdelivr.net/gh/<user>/<repo>@<branch|sha>/docs/faq.md).
  3. Markdown → HTML (marked/markdown-it) → DOMPurify.sanitize{@html} в Svelte.
  4. Относительные пути картинок переписываются на абсолютные URL CDN до рендера.
  5. Кэширование: jsDelivr держит кэш ветки ~12ч; для мгновенных апдейтов — либо @sha-пиннинг (SHA коммита кладётся в index.json/конфиг), либо cache-buster query.

Конфиг-вью (панель управления): отдельная страница того же Svelte-приложения (в консоли на неё указывает Panel Config Path; режим можно бранчить по query ?mode=config, которую Helper/Twitch сам подставляет). UI: список документов, добавить/удалить/скрыть, переупорядочить (drag&drop), заголовок, ссылка на .md, URL заголовочной картинки, кнопка «Сохранить» → Twitch.ext.configuration.set('broadcaster', '1', JSON.stringify(...)).

Картинки: заголовочные и inline-картинки кладутся в docs/assets/ в том же репо («загрузка картинки» = файл в репо; при желании конфиг-вью может коммитить их сам через GitHub REST API — см. открытый вопрос про PAT).

Альтернативы

  • С EBS (свой мини-бэкенд, CF Worker/Node): синк репо по webhook, прокси приватного репо, серверное хранение PAT, аплоад картинок из UI коммитом в репо. Плюсы: приватность, секреты, мгновенная инвалидация. Минусы: инфраструктура, которую мы хотели избежать.
  • Без git вообще (Twitch-only): нежизнеспособно — Config Service 5 КБ, файловый хост Twitch меняется только вместе с билдом версии. Именно поэтому внешний CDN — правильный путь.

4. Git-обновления: что именно работает

  • raw.githubusercontent.comACAO: *, кэш 5 мин.
  • cdn.jsdelivr.net/gh/...ACAO: *, кэш браузера 7 дней / CDN 12 ч; поддерживает пиннинг по ветке, тегу и SHA; есть минификация для gh.
  • GitHub Pages — тоже ACAO: * (можно собирать статический сайт/бандлы из репо).

Схема: ветка main = черновик, тег/отдельная ветка published = то, что показывает панель; в конфиг-сегменте храним ссылку с @published + SHA. Обновить контент = push + (при SHA-пиннинге) одно нажатие в конфиг-вью. Никакого пересборочного пайплайна не нужно, если MD рендерится на клиенте; при желании можно рендерить на CI (GitHub Actions → JSON-бандл в gh-pages), но это опция.


5. Плюсы и минусы

Плюсы

  • Нативное место на канале, виден офлайн — ровно то, что нужно для био/справки.
  • Одна панель слота — сколько угодно документов (табы), лимит только UX.
  • Хостинг кода на CDN Twitch бесплатно; контент — на CDN GitHub бесплатно. Ноль затрат, ноль серверов.
  • Обновление контента через git push — без пересборки расширения и без ревью (ревью нужно только коду при публичном релизе).
  • Config Service бесплатно решает per-channel настройки (у каждого стримера свой список документов).
  • Svelte 5 здесь уместен: маленький бандл (бюджет мобильной первой загрузки 1 МБ), рунеты ($state/$derived) закрывают весь стейт, Vite мульти-HTML сборка из коробки.
  • Версионирование контента бесплатно из коробки — это git.

Минусы / ограничения

  • 318×496 px — тесно для длинных документов; обязателен внутренний скролл и мобильный дизайн; pop-out спасает частично.
  • CSP-allowlist: каждый внешний домен (jsDelivr, GitHub) надо руками вносить в консоли на версию — забыл = контент молча не грузится.
  • Config Service публичен и мал (5 КБ) — секреты (PAT) там хранить нельзя.
  • Нет серверного аплоада картинок в CDN Twitch в рантайме — картинки только через репо/внешний хост.
  • Песочница: нет alert/confirm, нет вложенных iframe, storage API ограничен (localStorage под вопросом — проверить рано).
  • Platform risk: Twitch вправе менять правила (Rig уже убили); расширение не переносится на другие платформы.
  • Мобильная поддержка = отдельная mobile-ready версия вьюера + жёсткий бюджет 1 МБ/3 c.
  • Для публичного релиза — ревью с гайдом/скриншотами; Released-версию нельзя править, только новая версия.

6. Библиотеки

Назначение Пакет Комментарий
Фреймворк svelte@5, vite, @sveltejs/vite-plugin-svelte два entry: viewer.html / config.html
Markdown marked крошечный, GFM из коробки; либо markdown-it если нужны плагины
Санитайзер dompurify обязателен — контент внешний
Заголовки-анкоры marked-gfm-heading-id или github-slugger для оглавления в панели
Подсветка кода highlight.js (опц.) shiki тяжёлый для 1 МБ бюджета
Формулы katex (опц.) если нужны
Git API из UI octokit (опц.) коммит картинок из конфиг-вью
Local dev mkcert + Twitch CLI / community DevRig Rig от Twitch устарел; HTTPS обязателен даже локально
Типы Helper своя .d.ts для window.Twitch.ext npm-пакета с офиц. типами нет надёжного

CSS-фреймворк не нужен — свои стили на 318px проще.


7. Что надо сделать (по шагам)

  1. Консоль dev.twitch.tv → Extensions → создать (тип Panel), получить client id/secret.
  2. Скелет: Vite + Svelte 5, два entry (viewer.html, config.html), скрипт Helper в обоих, mkcert для localhost:8080, в консоли Base URI + пути вьюера/конфига.
  3. Вьювер: чтение configuration.broadcaster (fallback — дефолтный demo-документ), табы/пейджер, ленивый фетч MD, marked+DOMPurify, рерайт путей картинок, скролл.
  4. Конфиг-вью: CRUD списка документов + порядок + заголовочные картинки, сохранение сегмента через configuration.set. (В конфиг-вью JWT имеет роль broadcaster.)
  5. Репозиторий контента: docs/*.md, docs/assets/, index.json; CSP-домены в консоли.
  6. Тест на своём канале: Local Test → «Activate» в Extension Manager — ревью не нужно. Потом Hosted Test (загрузка zip) — проверить кэши CDN Twitch.
  7. (Опц.) Публичный релиз: walkthrough guide, change log, скриншоты, EULA/privacy → Review → Released или Invite Only (allowlist стримеров).

Оценка трудоёмкости MVP (вьювер + конфиг + репо + тест на своём канале): 2–4 вечера.


8. Открытые вопросы (решить в процессе)

  1. localStorage в sandbox — работает ли (кэш доков между сессиями)? Проверить первым делом.
  2. Точный лимит zip-билда в консоли (официальной цифры в доках нет, форум даёт ориентиры ~несколько МБ) — некритично, билд маленький.
  3. PAT для коммита картинок из конфиг-вью: в Config Service хранить нельзя (публично). Варианты: вводить PAT сессией (in-memory), хранить в EBS, или отказаться от аплоада из UI — картинки просто коммитятся в репо руками.
  4. Приватный контент? Если да — только через EBS-прокси (или приватный репо + Worker).
  5. Мобильная версия: делать ли mobile-ready сразу; как табы ведут себя в мобильной панели.
  6. Инвалидация кэша: ветка@main (до 12ч задержка jsDelivr) vs SHA-пиннинг (мгновенно, но нужно обновлять ссылку) — выбрать схему; компромисс: index.json на @main, он дешёвый.
  7. Pop-out у панелей — заявлен в доках дизайна; проверить фактическое поведение и размеры.
  8. Формат index.json и схема конфиг-сегмента (поля: id, title, headerImage, src, sha?, hidden, order) — зафиксировать до начала UI.

9. Источники


10. Итерация 2 — принятые решения

Уточнения от автора: подсветка кода и формулы не нужны; localStorage не нужен; приватность не нужна; интерактив только — кнопки листания и ссылки; попапы/pop-out не нужны; нужен билдер контента (кроп картинок, валидация MD, чистка лишнего); главный приоритет — надёжная загрузка контента.

10.1 Билд фронтенда → два самодостаточных HTML

  • Vite/Svelte по умолчанию НЕ дробит на мелкие чанки: код-сплиттинг возникает только из import() и нескольких entry с общими модулями. Правило: не использовать import() вообще (ленивость не нужна — контент лениво грузится по сети).
  • Схема: viewer.html и config.html, каждый собирается отдельным билдом через vite-plugin-singlefile → один HTML без внешних js/css (плагин официально не умеет multi-entry, поэтому два конфига: vite build -c vite.viewer.config.ts + vite build -c vite.config.config.ts). В консоли: Panel Viewer Path = /viewer.html, Panel Config Path = /config.html.
  • Размеры: вьювер без marked/DOMPurify (см. 10.2) ≈ 1525 КБ, конфиг-вью ≈ 40–60 КБ — на порядок ниже мобильного бюджета 1 МБ и лимитов zip.
  • Альтернатива (проще): обычный multi-entry Vite без плагина → 2 html + 3–4 hashed-ассета. Тоже корректно; single-file выигрывает надёжностью (меньше файлов, меньше шансов ошибиться в путях).
  • CSP в консоли: connect-src/img-src = cdn.jsdelivr.net + raw.githubusercontent.com. Шрифтов и GA нет — системный стек.

10.2 Пре-рендер MD в билдере + чистка на стороне зрителя

Рендер Markdown происходит на этапе сборки (в CI), но решение «убрать санитайзер из рантайма» пересмотрено (итерация 3): всё, что приходит с jsDelivr, считается недоверенными данными (репо/CDN могут быть подменены), а доверенный корень — это код вьювера (CDN Twitch) и конфиг-сегмент (Twitch). Чистка результата выполняется у зрителя, до вставки в DOM — два варианта в 10.7.

Пайплайн: git push → GitHub Actions → builder → ветка published → jsDelivr.

10.3 Билдер контента (Node-скрипт, builder/)

Стек: unified + remark-parse + remark-gfm (AST + валидация по белому списку), gray-matter (front-matter: title/order/hidden), sharp (картинки). Сериализация: основной вариант — компактный JSON-AST (своя функция ~50 строк поверх mdast, см. 10.7); запасной — remark-rehype + rehype-external-links + rehype-stringify → HTML (тогда во вьювере DOMPurify).

  • Валидация MD по белому списку узлов AST: heading, paragraph, emphasis/strong, list/listItem, link, image, blockquote, hr, break, text. Всё прочее (html, code, table, footnote…) = ошибка сборки с файлом и позицией. Белый список — один конфиг-объект.
  • Картинки (sharp): заголовочные — кроп 636×340 (318×170 @2x, fit:cover), webp; inline — max width 640, webp q≈80; метаданные стирать; битые пути = ошибка билда; лимит на размер исходника (напр. 10 МБ) и на суммарный вес документа.
  • Чистка лишнего (кандидаты, расширяемо): сырой HTML в MD; запрещённые конструкции; битые относительные ссылки/картинки; нормализация уровней заголовков (в панели не выше h4); трим пустых строк/пробелов; слишком большие документы → warning с размером.
  • Выход: published/index.json, published/docs/*.json (или *.html в варианте A), published/img/*; для каждого файла — SHA-256 в index.json (см. 10.7). Относительные пути картинок работают на jsDelivr как есть (всё под одним @sha-префиксом) — рерайт не нужен.

10.4 Схема доставки (надёжность важнее автомагии)

  • Индекс читается вьювером напрямую из CDN: https://raw.githubusercontent.com/<u>/<r>/published/index.json (TTL raw — 5 минут, файл крошечный). Каждый entry содержит абсолютные jsDelivr-URL, запиннованные на SHA коммита (.../gh/<u>/<r>@<sha>/published/...) — контент иммутабелен, нет расхождения TTL: индекс ссылается только на документы, которые уже существуют.
  • Обновление контента полностью автоматическое: push → CI собрал published → через ≤5 минут зрители видят новое. Никаких кнопок и заходов в панель.
  • broadcaster-сегмент — только per-channel исключения (опционально): {v:1, hidden:[id], order:[id]}; пустой сегмент = порядок из index.json.
  • Fallback: index недоступен → developer-сегмент → вшитый дефолтный документ.
  • localStorage не используется.

10.5 Pop-out и интерактив

  • Pop-out отключить нельзя — это кнопка UI Twitch (RFC 0008), не наша. Приложение просто остаётся нейтральным к размеру окна.
  • Интерактив внутри: только кнопки «‹ ›» и ссылки. Ссылки target=_blank разрешены песочницей (allow-popups), top-navigation заблокирован самим Twitch — «увести» зрителя со страницы нельзя.

10.6 Итоговый стек (после упрощений)

  • Приложение: svelte@5, vite, @sveltejs/vite-plugin-svelte, vite-plugin-singlefile. Всё.
  • Билдер: unified/remark-parse/remark-gfm, gray-matter, sharp (+rehype-стек в варианте A).
  • Убраны: marked, highlight.js, katex. DOMPurify — по варианту 10.7 (A: в рантайме ~8 КБ gzip; B: не нужен вовсе).

10.7 Санитизация на стороне зрителя (итерация 3)

Модель доверия: доверенный корень = билд расширения (CDN Twitch) + конфиг-сегмент (хранит Twitch, правит только аккаунт стримера). Всё, что приходит с jsDelivr (даже запиннованное на SHA) — недоверенные данные: компрометация репо/CDN/GitHub-аккаунта не должна приводить к исполнению чего-либо у зрителя. Важно: билдер живёт в том же репо и тоже подменяем — поэтому граница доверия обязана находиться в коде, который раздаёт Twitch.

Вариант B — рекомендуемый: JSON-AST вместо HTML, никакого {@html}

  • билдер сериализует проверенный mdast в компактное мини-дерево: ["h2","Заголовок"], ["p","текст ",["strong","жирный"]], ["ul",[["li","пункт"]]], ["a","https://…","текст"], ["img","https://…","alt"], ["blockquote",[…]], ["hr"];
  • вьювер — один рекурсивный Svelte-компонент (~80–120 строк): строка = текст (авто-эскейп Svelte), неизвестный узел = пропустить с warning в консоль, URL проверяется по схеме (img: только https на cdn.jsdelivr.net / raw.githubusercontent.com; a: только http/https), атрибуты кроме href/src/alt/title не мапятся вообще;
  • санитайзер не нужен: в DOM физически попадают только узлы, которые отрисовал наш код. Zero зависимостей — это и есть «попроще»: нечего чистить.

Вариант A — запасной: HTML в выдаче + DOMPurify во вьювере

~8–9 КБ gzip, одна строка вызова. Tight-конфиг: ALLOWED_TAGS по белому списку, ALLOWED_ATTR = ['href','src','alt','title'], ADD_ATTR: ['target']. Стандарт индустрии, mXSS закрыты, поддерживается активнее всех.

Дополнительный слой — SHA-256 пиннинг (напрямую закрывает «подменят файлы»)

  • билдер считает SHA-256 каждого файла выдачи → index.json → конфиг-сегмент;
  • вьювер: const buf = await (await fetch(url)).arrayBuffer(); const digest = await crypto.subtle.digest('SHA-256', buf); → сравнить со значением из сегмента; не совпало → экран «контент изменился, обновите список в панели управления». ~10 строк, ноль зависимостей, работает во всех браузерах (secure context).
  • согласуется с @sha-пиннингом: старый контент остаётся доступен по старому SHA, поэтому push без нажатия «Обновить список» ничего не ломает — зритель видит прежнюю версию.
  • картинки через <img> так не проверить; для них достаточно host-allowlist в рендерере; максимум паранойи — тот же fetch+hash+objectURL.

Остаточный риск (честно): компрометация Twitch-аккаунта = компрометация доверенного корня (атакующий правит сегмент и хеши). Это граница любой схемы без EBS.

10.8 Итерация 4 — финальные решения

  • SHA-256 слой отклонён: лишний обязательный шаг («обновила файл — зайди в панель и нажми кнопку») перевешивает пользу. Подмена ловится схемой B + host-allowlist.
  • Принят вариант B: JSON-AST, рекурсивный рендерер, никакого {@html} и санитайзера.
  • Доставка упрощена (см. 10.4): index.json вьювер берёт прямо с raw@published — контентные пуши доезжают до зрителей сами за ≤5 минут; сегмент — только исключения.
  • Реализован скелет: два single-file билда (viewer/config), вьювер с пейджером, конфиг-панель (скрыть/порядок), билдер (валидация + JSON-AST + sharp), sample-контент, CI.