29 KiB
Исследование: Twitch Panel Extension «мультидокументная панель»
Задача: расширение размером в одну Twitch-панель (в блоке «О канале»), внутри которого листается несколько документов на основе Markdown (+ заголовочные картинки). Управление контентом — через удобную админ-панель; желательно обновление через git. Фронтенд — Svelte 5.
1. Вердикт
Полностью реализуемо, без бэкенда, бесплатно. Платформа Twitch Extensions жива
(2025–2026, 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/картинок │ сохранение в конфиг-сегмент│
└─────────────────────────────┘
Поток данных во вьювер-вью:
- При старте Helper отдаёт
configuration.broadcaster(JSON со списком документов). - Приложение рендерит табы/карусель; по выбору таба лениво фетчит MD с jsDelivr
(
https://cdn.jsdelivr.net/gh/<user>/<repo>@<branch|sha>/docs/faq.md). - Markdown → HTML (
marked/markdown-it) →DOMPurify.sanitize→{@html}в Svelte. - Относительные пути картинок переписываются на абсолютные URL CDN до рендера.
- Кэширование: 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.com—ACAO: *, кэш 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. Что надо сделать (по шагам)
- Консоль dev.twitch.tv → Extensions → создать (тип Panel), получить client id/secret.
- Скелет: Vite + Svelte 5, два entry (
viewer.html,config.html), скрипт Helper в обоих,mkcertдля localhost:8080, в консоли Base URI + пути вьюера/конфига. - Вьювер: чтение
configuration.broadcaster(fallback — дефолтный demo-документ), табы/пейджер, ленивый фетч MD, marked+DOMPurify, рерайт путей картинок, скролл. - Конфиг-вью: CRUD списка документов + порядок + заголовочные картинки, сохранение
сегмента через
configuration.set. (В конфиг-вью JWT имеет роль broadcaster.) - Репозиторий контента:
docs/*.md,docs/assets/,index.json; CSP-домены в консоли. - Тест на своём канале: Local Test → «Activate» в Extension Manager — ревью не нужно. Потом Hosted Test (загрузка zip) — проверить кэши CDN Twitch.
- (Опц.) Публичный релиз: walkthrough guide, change log, скриншоты, EULA/privacy → Review → Released или Invite Only (allowlist стримеров).
Оценка трудоёмкости MVP (вьювер + конфиг + репо + тест на своём канале): 2–4 вечера.
8. Открытые вопросы (решить в процессе)
- localStorage в sandbox — работает ли (кэш доков между сессиями)? Проверить первым делом.
- Точный лимит zip-билда в консоли (официальной цифры в доках нет, форум даёт ориентиры ~несколько МБ) — некритично, билд маленький.
- PAT для коммита картинок из конфиг-вью: в Config Service хранить нельзя (публично). Варианты: вводить PAT сессией (in-memory), хранить в EBS, или отказаться от аплоада из UI — картинки просто коммитятся в репо руками.
- Приватный контент? Если да — только через EBS-прокси (или приватный репо + Worker).
- Мобильная версия: делать ли mobile-ready сразу; как табы ведут себя в мобильной панели.
- Инвалидация кэша: ветка@main (до 12ч задержка jsDelivr) vs SHA-пиннинг (мгновенно, но нужно обновлять ссылку) — выбрать схему; компромисс: index.json на @main, он дешёвый.
- Pop-out у панелей — заявлен в доках дизайна; проверить фактическое поведение и размеры.
- Формат index.json и схема конфиг-сегмента (поля: id, title, headerImage, src, sha?, hidden, order) — зафиксировать до начала UI.
9. Источники
- Extensions overview / hosting / CSP: https://dev.twitch.tv/docs/extensions/
- Building (Helper, Config Service 5 КБ, PubSub): https://dev.twitch.tv/docs/extensions/building/
- Designing (панель 318×496, pop-out): https://dev.twitch.tv/docs/extensions/designing/
- Life cycle (Local Test, ревью, Released): https://dev.twitch.tv/docs/extensions/life-cycle/
- Guidelines & Policies (мобайл 1 МБ): https://dev.twitch.tv/docs/extensions/guidelines-and-policies/
- Mobile-видимость панелей: https://help.twitch.tv/s/article/how-to-use-extensions
- Статус платформы / deprecation Rig: https://dev.twitch.tv/docs/product-lifecycle/ и https://barrycarlyon.co.uk/wordpress/2023/04/04/the-twitch-extensions-developer-rig-is-dead/
- CORS jsDelivr/GitHub: проверено curl-ом (ACAO: * на raw.githubusercontent.com, cdn.jsdelivr.net, pages)
- Pop-out как элемент UI Twitch (отключить нельзя): RFC 0008 https://discuss.dev.twitch.com/t/rfc-0008-pop-out-support-for-extensions/16920
- vite-plugin-singlefile (один вход на билд, multi-entry не поддерживается): https://github.com/richardtallent/vite-plugin-singlefile
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) ≈ 15–25 КБ, конфиг-вью ≈ 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.