
Всем привет! Меня зовут Александр, я продуктовый инженер в KTS.
Недавно мы разрабатывали AI-копайлот для сервис-деска в виде расширения на Chrome. Копайлот подсказывал оператору ответы для клиента на основе контекста диалога, истории обращений и базы знаний компании. Уже на старте стало понятно, что разработка расширений сильно отличается от привычной фронтенд-разработки.
Основная сложность была не столько в реализации конкретных фич, сколько в архитектуре: где должен жить тот или иной код, как организовать взаимодействие между частями расширения и как не заложить проблемы на будущее. Дополнительно добавились нюансы интеграции в страницу и ограничения, связанные с публикацией в Chrome Web Store.
Поударявшись о всевозможные подводные камни, я решил написать эту статью. В ней я разберу ключевые этапы разработки и публикации, покажу удачные и неудачные подходы и на что стоит обратить внимание, если вы планируете делать браузерное расширение со сложной логикой.
Сразу задам фрейм: в статье речь пойдет именно о расширениях для Chrome (Manifest V3), хотя многие подходы будут применимы и к другим браузерам на базе Chromium.
Старт разработки
Первым вопросом был выбор инструмента для разработки. На первый взгляд кажется, что можно просто создать обычное React-приложение на Vite и начать писать код. Однако браузерное расширение состоит из нескольких независимых частей (popup, content scripts, background/service worker), требует специального манифеста и особой схемы сборки. Настроить все это вручную можно, но по мере роста проекта конфигурация быстро усложняется.
Поэтому я решил посмотреть, какие инструменты уже существуют для разработки расширений и насколько хорошо они сочетаются с нашим текущим стеком, состоящим из:
React;
Typescript;
React Router;
React Query.
Еще мне было важно, чтобы инструмент позволял подробно разобраться в том, как это все работает «под капотом», и не скрывал какие-то слои под уровнями абстракций.
В итоге выбор был сделан в пользу плагина CRXJS, так как он:
использует привычный нам Vite в качестве сборщика;
имеет архитектуру, близкую к обычному React-приложению;
дает возможность гибко масштабировать проект по мере роста функциональности.
Отдельным преимуществом стало то, что CRXJS не скрывает базовые механизмы работы браузерных расширений, что упрощает поддержку и делает архитектуру более прозрачной для команды.
CRXJS предлагает свое руководство по созданию простого браузерного расширения с нуля. Примитивное расширение может состоять всего из четырех файлов:
index.html — UI расширения;
manifest.config.js — файл, который хранит базовую информацию о расширении и указывает точку входа в приложение;
package.json — файл с зависимостями;
vite.config.js — файл с настройками сборщика.
В таком расширении не будет никакого JS-кода — только html-разметка в файле index.html:

Дублировать руководство здесь я не буду, сразу перейду к более сложным штукам. Ниже мы рассмотрим варианты файловой структуры более сложных браузерных расширений, которые включают в себя JS-логику.
Структура браузерного расширения
Сейчас в браузерных расширениях фактически используется Manifest V3 — современный стандарт Chrome (и большинства Chromium-браузеров), который пришёл на смену Manifest V2. Переход от V2 к V3 связан с несколькими изменениями на практике:
ограничение фоновых страниц в пользу service workers (нет постоянного background-скрипта);
более строгая модель сетевых запросов через declarativeNetRequest вместо webRequest;
более жесткая политика доступа к данным и сайтам через permissions и host_permissions;
улучшенная изоляция и контроль за выполнением кода расширения.
Базовая структура браузерного расширения на V3 — это набор изолированных runtime-контекстов (popup, content scripts, service worker, side panel), которые взаимодействуют через messaging API и совместно формируют поведение расширения.
Теперь перейдем к ключевым файлам.
manifest – сердце расширения
Это сердце расширения. Именно через него браузер знакомится с приложением и начинает «понимать»:
что умеет расширение;
какие права ему нужны;
какие скрипты запускать, где и когда их запускать;
на каких сайтах оно будет работать.
popup – интерфейс расширения
Popup — это окно, которое открывается по клику на иконку расширения. С точки зрения React-разработчика, это обычное SPA-приложение.
Тут оговорюсь, что помимо popup есть и другие способы реализации UI расширения, о которых мы поговорим позже.
background – центральная логика расширения
Background можно воспринимать как центрального координатора всего расширения. Background не отображает интерфейс и не работает напрямую со страницей сайта. Его основная задача — координировать работу остальных частей расширения.
Как правило, именно в background располагаются:
API-запросы;
работа с авторизацией;
обработка событий браузера;
организация взаимодействия между popup и content scripts.
Background является наиболее подходящим местом для хранения централизованной runtime-логики расширения, так как:
popup может быть закрыт пользователем;
content script существует только внутри конкретной вкладки.
Если background необходимо взаимодействовать с сайтом, он делает это через content scripts.
В Manifest V3 background реализуется в виде service worker. Он запускается по событию и может автоматически выгружаться браузером при отсутствии активности. Поэтому состояние, которое должно сохраняться между запусками service worker, обычно хранится в chrome.storage, IndexedDB или других механизмах постоянного хранения данных.
content scripts – логика расширения внутри сайта
Content script — это код расширения, который браузер запускает в контексте открытой веб-страницы. Благодаря content scripts расширение может взаимодействовать с содержимым сайта и его интерфейсом. В отличие от popup и background, content script работает внутри конкретной вкладки браузера и имеет доступ к DOM страницы.
Обычно content scripts используются для:
чтения содержимого страницы;
изменения DOM;
вставки собственного UI поверх сайта;
отслеживания действий пользователя на странице;
получения данных со страницы для дальнейшей обработки расширением.
Content script существует только в рамках конкретной вкладки и не может напрямую взаимодействовать с другими. Для обмена данными с background или popup обычно используется механизм сообщений (message passing).
Сетап расширения с background и content scripts
Структура файлового расширения с использованием background script и content script может выглядеть следующим образом:

Логика примерно следующая:
Файл манифеста показывает, что старт приложения начинается из default_popup – index.html.
index.html подключает файл src/main.tsx.
main.tsx начинает исполняться и встраивает React-приложение в DOM-дерево попапа.
Примеры файлов под спойлерами.
manifest.config.js
import { defineManifest } from '@crxjs/vite-plugin'; import packageJson from './package.json'; export default defineManifest({ manifest_version: 3, name: 'KTS Browser Extension', version: packageJson.version, description: 'Браузерное расширение от KTS', action: { default_title: 'KTS Browser Extension', default_icon: { '16': 'public/icons/icon-16.png', '48': 'public/icons/icon-48.png', '128': 'public/icons/icon-128.png', }, }, background: { service_worker: 'src/background.ts', type: 'module', }, content_scripts: [ { matches: ['http://*/*', 'https://*/*'], js: ['src/content-script.ts'], }, ], web_accessible_resources: [ { resources: ['index.html', 'assets/*', 'public/*'], matches: ['http://*/*', 'https://*/*'], }, ], icons: { '16': 'public/icons/icon-16.png', '48': 'public/icons/icon-48.png', '128': 'public/icons/icon-128.png', }, });
index.html
<!doctype html> <html lang="ru"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>KTS Browser Extension</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body> </html>
main.tsx
import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { HashRouter } from 'react-router-dom'; import { MantineProvider } from '@mantine/core'; import '@mantine/core/styles.css'; import { App } from './App'; import { theme } from './theme'; import './index.css'; createRoot(document.getElementById('root')!).render( <StrictMode> <MantineProvider theme={theme} forceColorScheme="dark"> <HashRouter> <App /> </HashRouter> </MantineProvider> </StrictMode>, );
Обратите внимание, что используется не привычный Browser Router, а именно HashRouter — это связано со спецификой работы роутинга в расширениях.
Полностью код можно посмотреть на GitHub.
Запуск браузерного расширения
Чтобы посмотреть на получившийся результат, нужно:
Сбилдить бандл расширения с помощью команды yarn dev (для дев-разработки) или в yarn build (для продакшена).
Загрузить бандл в менеджер расширений.
В менеджер расширений можно перейти по кнопке пазла в правом верхнем углу (если вы используете Google Chrome), либо написав в адресной строке браузера chrome://extensions/. Обязательно включите Developer Mode, чтобы иметь возможность загрузить расширение.

Далее нажмите кнопку Load unpacked и загрузите папку dist.

После этого по той же иконке пазла можно вызвать расширение на любой странице:

Здесь стоит отметить, что не все ошибки из браузерного расширения будут отображаться на странице, на которой будет открыто расширение. Все зависит от того, в каком из контекстов возникла ошибка.
Ошибки из service-workers (background-файлы) можно увидеть, если перейти в service-worker на карточке самого расширения по ссылке service worker:

Он выглядит аналогично DevTools:

Способы встраивания
default_popup
Это способ, который мы рассмотрели выше. Манифест указывает default_popup: index.html как точку входа. В свою очередь, файл index.html может подключить какой-нибудь JS-скрипт.
Плюсы:
простая реализация;
полностью изолирован от страницы, на которой открывается расширение.
Минусы:
закрывается при потере фокуса;
не подходит для длительной работы с интерфейсом;
плохо подходит для сценариев, где нужны длительные и сложные взаимодействия со страницей.
Content scripts + внедрение UI в DOM
Если ваше расширение должно работать только в рамках одного сайта, вам хорошо подойдет подход с встраиванием расширения в DOM-дерево. В этом случае в файле манифеста точкой входа являются файлы content-scripts — они обеспечивают старт и отдельно загружают файл index.html, либо вставляют ноды напрямую в DOM-дерево
Файл background в данном случае обрабатывает клик на иконку браузерного расширения в меню и отправляет событие об этом в content-script.
manifest.config.js
import { defineManifest } from '@crxjs/vite-plugin'; import packageJson from './package.json'; export default defineManifest({ manifest_version: 3, name: 'KTS Browser Extension', version: packageJson.version, description: 'Браузерное расширение от KTS', action: { default_title: 'KTS Browser Extension', default_icon: { '16': 'public/icons/icon-16.png', '48': 'public/icons/icon-48.png', '128': 'public/icons/icon-128.png', }, }, background: { service_worker: 'src/background.ts', type: 'module', }, content_scripts: [ { matches: ['http://*/*', 'https://*/*'], js: ['src/content-script.ts'], }, ], web_accessible_resources: [ { resources: ['index.html', 'assets/*', 'public/*'], matches: ['http://*/*', 'https://*/*'], }, ], });
background.js
const TOGGLE_WIDGET_MESSAGE = 'KTS_EXTENSION_TOGGLE_WIDGET'; chrome.action.onClicked.addListener((tab) => { if (!tab.id) { return; } void chrome.tabs.sendMessage(tab.id, { type: TOGGLE_WIDGET_MESSAGE }).catch(() => { // Content scripts are only available on regular http(s) pages. }); });
content-script.js
const WIDGET_ID = 'kts-browser-extension-widget'; const TOGGLE_WIDGET_MESSAGE = 'KTS_EXTENSION_TOGGLE_WIDGET'; function createWidget() { const widget = document.createElement('div'); widget.id = WIDGET_ID; widget.style.position = 'fixed'; widget.style.top = '24px'; widget.style.right = '24px'; widget.style.width = '420px'; widget.style.height = '600px'; widget.style.border = '1px solid rgba(255, 255, 255, 0.18)'; widget.style.borderRadius = '16px'; widget.style.overflow = 'hidden'; widget.style.boxShadow = '0 20px 60px rgba(0, 0, 0, 0.35)'; widget.style.background = '#1a1b1e'; widget.style.zIndex = '2147483647'; const iframe = document.createElement('iframe'); iframe.src = chrome.runtime.getURL('index.html'); iframe.title = 'KTS Browser Extension'; iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = '0'; widget.append(iframe); return widget; } function toggleWidget() { const existingWidget = document.getElementById(WIDGET_ID); if (existingWidget) { existingWidget.remove(); return; } document.body.append(createWidget()); } chrome.runtime.onMessage.addListener((message: { type?: string }) => { if (message.type === TOGGLE_WIDGET_MESSAGE) { toggleWidget(); } });
index.html
<!doctype html> <html lang="ru"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>KTS Browser Extension</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body> </html>
main.tsx
import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { HashRouter } from 'react-router-dom'; import { MantineProvider } from '@mantine/core'; import '@mantine/core/styles.css'; import { App } from './App'; import { theme } from './theme'; import './index.css'; createRoot(document.getElementById('root')!).render( <StrictMode> <MantineProvider theme={theme} forceColorScheme="dark"> <HashRouter> <App /> </HashRouter> </MantineProvider> </StrictMode>, );
Весь код для этого подхода с внедрением UI расширения можно посмотреть здесь.
Плюсы:
максимальная интеграция с сайтом;
отлично подходит для обработки событий пользователя на самой веб-странице;
можно гибко настраивать положение расширения в UI на странице;
можно гибко настраивать логику видимости расширения.
Минусы:
сложен в реализации: внедрение в DOM, взаимодействие файлов между собой;
потенциальные конфликты CSS, JS: стили и скрипты от расширения могут конфликтовать со стилями и скриптами самой страницы;
сайт может блокировать такой подход с помощью CSP.

Side panel
Одним из модных способов интеграции браузерного расширения в пользовательский интерфейс является использование Side Panel. Это боковая панель браузера, которая открывается рядом с содержимым текущей вкладки и остаётся доступной пользователю во время работы с сайтом.
manifest.config.js
import { defineManifest } from '@crxjs/vite-plugin'; import packageJson from './package.json'; export default defineManifest({ manifest_version: 3, name: 'KTS Browser Extension', version: packageJson.version, description: 'Браузерное расширение от KTS', permissions: ['sidePanel'], action: { default_title: 'KTS Browser Extension', default_icon: { '16': 'public/icons/icon-16.png', '48': 'public/icons/icon-48.png', '128': 'public/icons/icon-128.png', }, }, side_panel: { default_path: 'index.html', }, background: { service_worker: 'src/background.ts', type: 'module', }, });
index.html
<!doctype html> <html lang="ru"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>KTS Browser Extension</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body> </html>
main.tsx
import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { HashRouter } from 'react-router-dom'; import { MantineProvider } from '@mantine/core'; import '@mantine/core/styles.css'; import { App } from './App'; import { theme } from './theme'; import './index.css'; createRoot(document.getElementById('root')!).render( <StrictMode> <MantineProvider theme={theme} forceColorScheme="dark"> <HashRouter> <App /> </HashRouter> </MantineProvider> </StrictMode>, );
Примерно так это будет выглядеть:

Плюсы:
UI расширения явно отделен от страницы сайта;
не закрывается при потере фокуса;
видимость сохраняется при переходе между страницами;
независим от самой страницы, поэтому конфликты файлов и действий исключены.
Минусы:
доступна только в Chromium-браузерах: Google Chrome, Microsoft Edge, Opera (частично). Нет поддержки в Firefox, Safari;
сложна в реализации взаимодействия с DOM страницы.
Остальные способы встраивания
Встроенными UI-контейнерами браузера, которые я рассмотрел выше, варианты не ограничиваются. Есть несколько способов открыть интерфейс расширения отдельно, фактически как самостоятельную HTML-страницу, изолированную от сайта и стандартных поверхностей браузера.
Отдельное окно через chrome.windows.create
Один из наиболее прямых способов — открыть интерфейс расширения в отдельном окне браузера с помощью chrome.windows.create. В этом случае расширение фактически рендерит свою HTML-страницу как отдельное приложение.
Замена новой вкладки (Override New Tab)
Другой способ — переопределение стандартной страницы новой вкладки браузера. В этом случае расширение подменяет системную страницу New Tab своей HTML-страницей.
Панель в DevTools (DevTools Extension)
Ещё один способ отрисовать интерфейс расширения — добавить собственную панель внутри инструментов разработчика браузера (DevTools).
В этом случае расширение регистрирует отдельную вкладку в DevTools, которая открывается рядом с Console, Elements и другими стандартными панелями. Фактически это тоже HTML-интерфейс, который живёт в отдельном окружении и работает в контексте текущей страницы.
Чаще всего этот способ используется не для пользовательских интерфейсов, а для инструментов разработчика — например, для дебага приложений, анализа состояния или работы с фреймворками.
О других менее популярных способах можно почитать здесь в документации Chrome.
Полезные свойства и доступы
Для корректной работы расширения необходимо описать его возможности в манифесте.
На основании манифеста браузер определяет, к каким API, сайтам и данным расширение получит доступ.
Содержимое манифеста напрямую влияет как на процесс публикации, так и на работу расширения на разных сайтах. Некоторые настройки требуют дополнительного обоснования при публикации, а отдельные механизмы безопасности сайтов могут ограничивать заявленную функциональность. Поэтому при проектировании расширения рекомендуется запрашивать только те разрешения и возможности манифеста, которые действительно необходимы для функциональности продукта.
Чуть подробнее я разберу возможные ограничения ниже, в разделе про публикацию расширения в Chrome Web Store. А пока поговорим о том, что и как задается в манифесте.
Свойства manifest-файла
1. host_permissions
Это список сайтов (origin'ов), к которым расширению разрешено получать привилегированный доступ. Само по себе оно не запускает код на сайте и не делает инжект, а только дает права на работу с этим сайтом.
Нужно для:
динамического инжекта скриптов через chrome.scripting.executeScript() на указанных сайтах (используется в связке со свойством content_scripts: matches);
выполнения запросов (fetch) из service worker/background к указанным доменам;
работы с cookies этих сайтов через chrome.cookies;
получения чувствительных данных вкладки (tab.url, tab.title, favIconUrl) для этих сайтов;
доступа к некоторым сетевым API браузера, связанным с указанными хостами;
запроса прав у пользователя на конкретные сайты вместо доступа ко всем сайтам.
2. declarative_net_request
Описывает систему правил для изменения сетевых запросов. Подключается следующим образом:
"declarative_net_request": { "rule_resources": [ { "id": "ruleset_1", "enabled": true, "path": "rules.json" } ] },
В файле rules.json хранятся сами правила. Для них указываются:
id правила;
приоритет правила;
условие, при котором должно сработать правило.
само действие, которое должно произойти при срабатывании.
Подробнее эти правила я разберу в конце, в разделе Кейс: использование виджета в качестве браузерного расширения.
3. content_security_policy
Указывает на ограничение использования JS внутри расширения.
4. content_scripts + matches + all_frames
Показывают, какие файлы на каких страницах сайта должны запускаться автоматически.
5. web_accessible_resources
Показывает, какие файлы расширения доступны внутри страницы.
Пример:
{ "web_accessible_resources": [ { "resources": ["assets/*"], "matches": ["<all_urls>"] } ] }
Разрешения manifest.permissions
Свойство permissions показывает браузеру и пользователю какие разрешения ему нужны для работы. Мы рассмотрим самые часто используемые свойства:
1. storage
Дает доступ к хранилищу расширения. Нужно для:
сохранения токенов;
сохранения настроек;
кеширования данных;
хранения состояния между перезапусками браузера.
Без него не работают:
chrome.storage.local;
chrome.storage.sync;
chrome.storage.session.
2. tabs
Дает доступ к управлению вкладками. Нужно, чтобы вкладки можно было:
искать;
переключать;
открывать;
закрывать;
перезагружать.
3. activeTab
Частный случай свойства tabs. Дает временный доступ к текущей вкладке после действия пользователя. Нужно для:
чтения содержимого текущей страницы;
получения URL активной вкладки.
4. scripting
Нужно для:
внедрения JS в открытую вкладку;
внедрения CSS;
запуска скрипта после клика пользователя.
Без него не работают:
chrome.scripting.executeScript(...);
chrome.scripting.insertCSS(...).
5. sidePanel
Дает доступ к Side Panel API. Нужно для:
открытия боковой панели;
управления ее состоянием.
Без него не работают:
chrome.sidePanel.open(...);
chrome.sidePanel.setOptions(...).
6. contextMenus
Дает доступ к контекстному меню. Нужно для:
добавления пункта по правому клику;
обработки выбора текста.
Без него не работает chrome.contextMenus.create(...).
7. declarativeNetRequest и declarativeNetRequestWithHostAccess
Дает доступ к движку правил сетевых запросов. Нужно для:
блокировки рекламы;
редиректов;
изменения заголовков.
В большинстве случаев DNR работает через rulesets. Собственно, вся идея DNR заключается в том, что ты описываешь набор правил, а браузер применяет их самостоятельно. Например, следующий код помогает внедрить скрипт на страницу в обход ограничений браузера:
[ { "id": 1, "priority": 1, "action": { "type": "modifyHeaders", "responseHeaders": [ { "header": "content-security-policy", "operation": "remove" } ] }, "condition": { "urlFilter": "*site-for-inject.com*", "resourceTypes": ["main_frame", "sub_frame"] } },
Есть другие полезные permissions, которые используются реже (например, notifications, cookies, downloads, bookmarks и другие). Подробнее о них вы можете почитать здесь.
Публикация расширения в Google Store
Регистрация аккаунта
Для публикации в Chrome Web Store необходимо зарегистрировать аккаунт разработчика и оплатить единоразовый регистрационный взнос. После этого становится доступна публикация расширений и управление их версиями.
Стоит заранее продумать, кто будет поддерживать расширение. В консоли можно добавить других участников команды и выдать им различные уровни доступа, чтобы обновления не зависели от одного человека.

Подготовка страницы расширения
Помимо самого кода, потребуется подготовить материалы для страницы в магазине:
название и краткое описание;
подробное описание функциональности;
иконки разных размеров;
скриншоты работы расширения;
при необходимости — рекламные изображения для продвижения.
Хорошо оформленная страница влияет не только на одобрение, но и на доверие пользователей.
Один из самых важных моментов — запрашивать только те разрешения, которые действительно необходимы. Например:
{ "permissions": ["storage", "tabs"] }
Каждое дополнительное разрешение вызывает вопросы как у пользователей, так и у модерации. Особенно внимательно проверяются:
tabs;
scripting;
webRequest;
cookies;
доступ ко всем сайтам (<all_urls>).
Чем меньше привилегий требует расширение, тем проще проходит проверка.
Privacy Policy
Если расширение:
отправляет данные на сервер;
работает с пользовательскими сообщениями;
собирает аналитику;
использует авторизацию,
то потребуется политика конфиденциальности (Privacy Policy).
Сейчас Chrome уделяет этому гораздо больше внимания, чем несколько лет назад.
Обоснование разрешений
Для чувствительных разрешений Chrome может попросить объяснить:
зачем они нужны;
как используются;
какие данные обрабатываются.
Поэтому полезно заранее подготовить краткое описание архитектуры расширения и сценариев использования.
Типичные примеры ограничений и причин для дополнительной проверки:
использование разрешения host_permissions: ["<all_urls>"], предоставляющего доступ ко всем сайтам;
запрос доступа к API tabs, позволяющему получать информацию об открытых вкладках пользователя, когда это не требуется;
регистрация content_scripts на всех сайтах вместо ограниченного списка доменов;
наличие фонового процесса (background.service_worker), выполняющего действия вне пользовательского интерфейса;
перехват или модификация сетевых запросов через API наподобие declarativeNetRequest;
внедрение пользовательского интерфейса в DOM страницы через content scripts;
взаимодействие с внешними серверами через дополнительные разрешения и настройки Content Security Policy.
Проверка перед публикацией
Перед отправкой желательно протестировать:
установку с нуля;
обновление со старой версии;
работу после перезагрузки браузера;
работу на разных сайтах;
миграции данных в chrome.storage.
Многие ошибки проявляются именно после обновления версии, а не при чистой установке.
Модерация
После загрузки новой версии расширение проходит автоматическую и иногда ручную проверку.
Скорость проверки зависит от:
набора разрешений;
изменений относительно предыдущей версии;
наличия удалённого кода;
истории аккаунта разработчика.
Небольшие обновления могут пройти за несколько минут, а более серьезные изменения иногда проверяются несколько дней.
Запрет на удаленный код
Это один из самых частых сюрпризов для разработчиков. Chrome запрещает загружать и исполнять JavaScript-код с внешних серверов: <script src="https://my-server.com/script.js"></script> или eval(serverResponse).
Весь исполняемый код должен находиться внутри пакета расширения. Исключение составляют страницы, загружаемые через iframe: внутри них может работать обычное веб-приложение со своими скриптами, поскольку этот код уже не считается частью расширения.
Поэтому если приложение использует динамически загружаемые скрипты, архитектуру придется адаптировать под требования Chrome.
Чек-лист
Из практики разработчиков расширений чаще всего забывают:
добавить Privacy Policy;
минимизировать список разрешений;
проверить обновление со старой версии;
протестировать работу после перезапуска браузера;
подготовить качественные скриншоты;
настроить доступы для нескольких участников команды;
убедиться, что в расширении нет удаленно исполняемого кода.
Publisher account
После регистрации в Chrome Web Store разработчик получает возможность создавать отдельные Publisher Accounts — сущности, от имени которых публикуются расширения. На первый взгляд может показаться, что расширение привязано непосредственно к Google-аккаунту разработчика, однако фактически между ними существует дополнительный уровень:

Publisher Account выступает контейнером для расширений и позволяет управлять ими независимо от личного аккаунта разработчика.
На странице профиля можно увидеть информацию о developer account, статус регистрации и список созданных Publisher Accounts.
Главное преимущество Publisher Account — возможность отделить проект от конкретного человека. Если расширение публикуется через личный аккаунт разработчика, со временем могут возникнуть сложности:
сотрудник увольняется;
теряется доступ к аккаунту;
проект передаётся другой команде;
появляется необходимость подключить нескольких разработчиков.
Использование отдельного Publisher Account помогает избежать подобных проблем и делает управление расширением более прозрачным.
Для Publisher Account можно настраивать доступы и приглашать других участников команды. Это позволяет:
публиковать новые версии нескольким разработчикам;
разделять обязанности между участниками;
не хранить доступ к релизам у одного человека.
Для корпоративных проектов такой подход фактически является обязательным. Если расширение создается для компании или заказчика, лучше сразу публиковать его через отдельный Publisher Account, а не через личный Google-аккаунт разработчика.
Вот так выглядит создание нового Publisher Account. Обратите внимание, что на один Google-аккаунт можно дополнительно создать всего один Publisher Account:

И затем уже в сам Publisher Account можно добавлять пользователей:

Trade Account / Trader Status
Если расширение распространяется в коммерческих целях, особенно для пользователей из ЕС, появляется тема trader/non-trader статуса. На странице расширения может отображаться информация о разработчике и его статусе предпринимателя или не-предпринимателя.
Также могут потребоваться:
юридические данные;
адрес;
контактная информация;
дополнительные проверки аккаунта.
Некоторые разработчики неожиданно обнаруживают, что на странице расширения с trader-аккаунтом отображаются личные данные, которые они не планировали публиковать.

Мониторинг ошибок в Sentry
После публикации расширения полезно как можно раньше подключить систему мониторинга ошибок. В нашем случае для этого использовался Sentry. Однако при работе с браузерными расширениями есть один нюанс: далеко не все ошибки, которые вы увидите в Sentry, относятся к вашему коду.
Пользовательский браузер обычно содержит множество сторонних расширений:
переводчики;
блокировщики рекламы;
менеджеры паролей;
корпоративные расширения;
инструменты разработчика.
В Sentry существует специальная настройка:
Filter out errors known to be caused by browser extensions
Для обычных веб-приложений ее часто оставляют включенной: Sentry отфильтровывает ошибки, вызванные сторонними расширениями пользователей, что уменьшает количество шума в отчетах. Это позволяет значительно уменьшить количество шума и сосредоточиться на ошибках, действительно относящихся к вашему продукту.
Однако если вы разрабатываете само браузерное расширение, стоит проверить состояние этой настройки. В противном случае Sentry может отфильтровывать ошибки вашего расширения, и часть событий просто не будет попадать в мониторинг.

Также для расширений полезно разделять ошибки по источникам: popup, background/service worker и content scripts. Это существенно упрощает расследование инцидентов, поскольку проблемы в каждом из этих контекстов обычно имеют разную природу.
Хранение сторонних скриптов в бандле расширения
При разработке браузерного расширения важно учитывать ограничения Chrome Web Store на использование стороннего кода.
В отличие от обычных веб-приложений, расширение не может загружать и выполнять JavaScript-код с внешних серверов во время работы. Все исполняемые скрипты должны входить в состав пакета расширения и поставляться вместе с его сборкой.
Такое ограничение повышает безопасность расширений и позволяет Chrome Web Store проверять весь код, который будет выполняться на стороне пользователя. Однако при проектировании архитектуры этот нюанс стоит учитывать заранее, поскольку он может потребовать изменений в привычном подходе к загрузке внешних ресурсов.
Обновление расширения
Отдельной кнопки «Обновить расширение» в Chrome Web Store не существует — обновление представляет собой публикацию новой версии уже существующего расширения. Это отличает Chrome Web Store от многих мобильных магазинов приложений и поначалу может быть неочевидно.
Каждая новая публикация должна содержать увеличенный номер версии:
{ "version": "1.2.0" }
После публикации расширения процесс выпуска новых версий практически не отличается от обычного релизного цикла веб-приложения. Разработчик собирает новую версию расширения, увеличивает номер версии в manifest.json и загружает обновленный пакет в Chrome Web Store.
Новая версия проходит проверку модерацией, после чего становится доступна пользователям. При этом переустанавливать расширение вручную не требуется — браузер автоматически скачивает и устанавливает обновление.
Важно учитывать, что обновление распространяется не мгновенно. Между публикацией новой версии и ее появлением у пользователей может пройти некоторое время, поэтому в течение переходного периода разные пользователи могут работать на разных версиях расширения.

Кейс: использование виджета в качестве браузерного расширения
Теперь немного о практике с проекта, который я упомянул в предисловии. У одного из наших заказчиков уже существовало веб-приложение для общения с пользователями. Через чат-виджет посетители сайта задавали вопросы, а сотрудники поддержки отвечали им в режиме реального времени.
Позже появилась новая задача: предоставить сотрудникам поддержки аналогичный интерфейс для работы на сторонних площадках, где встроить существующий виджет было невозможно без доступа к их коду. Для этого было решено разработать браузерное расширение.
Поскольку значительная часть бизнес-логики чата уже была готова, мы решили переиспользовать ее в расширении вместо создания решения с нуля. Это позволило избежать дублирования функциональности и сосредоточиться на доработке текущего решения, адаптировав его для работы в формате браузерного расширения.
Хочется рассказать логику работы данного расширения и тонкости использования свойств и правил, с которыми мы столкнулись. Обо всем по порядку.
Взаимодействие файлов между собой
Всего в приложении можно было выделить 3 изолированных друг от друга контекста:
React-компоненты;
файл background.js;
content-scripts файлы.
Мы выбрали способ взаимодействия через событийную модель. Файл хранил все ивенты из приложения:
// Extension custom events export enum EXTENSION_CUSTOM_EVENT { TOGGLE_EXTENSION = 'TOGGLE_EXTENSION', CHECK_AUTH_BG = 'CHECK_AUTH_BG', INJECT_SCRIPT = 'INJECT_SCRIPT', REMOVE_WIDGET = 'REMOVE_WIDGET', CHECK_WIDGET_INITIALIZED = 'CHECK_WIDGET_INITIALIZED', REFRESH_EXTENSION = 'REFRESH_EXTENSION', RELOAD_TABS = 'RELOAD_TABS', }
Далее файлы подписывались на обработку этих ивентов.
Чтобы инициализировать наше приложение, формировалась следующая цепочка:
1. В React-компоненте мы вызывали функцию utils/addScriptStrBody – функция просто формировала текст нашего скрипта для вставки:await addScriptStrToBody({ scriptText });
Но корректно встроить скрипт мы можем только из background-файла. Поэтому функция addScriptToStrBody лишь прокидывала нужный ивент:
chrome.runtime.sendMessage( { type: EXTENSION_CUSTOM_EVENT.INJECT_SCRIPT, scriptCode: code, },
2. Файл backround уже имеет опцию внедрения скриптов и успешно делал это:
// Execute script in MAIN world (page context) chrome.scripting .executeScript({ target: { tabId }, world: 'MAIN', // Execute in page context, not content script context func: (code: string, rootScriptId: string) => { console.log('🎯 Page context: About to inject script'); // Create script element with ID for later removal const scriptNode = document.createElement('script'); scriptNode.id = rootScriptId; scriptNode.type = 'text/javascript'; scriptNode.textContent = code; (document.head || document.documentElement).appendChild(scriptNode); console.log('📌 Script element added to DOM'); }, args: [scriptCode, HTML_ID_INJECT_ROOT_SCRIPT], })
Обратите внимание: здесь довольно специфичный случай использования executeScript с wolrd: 'MAIN' и args, потому что нам было нужно, чтобы скрипт имел доступ к window страницы.
3. А вот таким образом мы управляли состоянием отображения нашего расширения. Клик на иконку расширения в адресной строке мы можем обрабатывать только через chrome.action.onClicked.addListener, который доступен в файле background.js:
chrome.action.onClicked.addListener((tab) => { if (tab.id) { chrome.tabs.sendMessage(tab.id, { type: EXTENSION_CUSTOM_EVENT.TOGGLE_EXTENSION }); } });
4. Сама логика расширения хранилась в content-script файле, так как для инжекта виджета нужен был доступ к странице:
chrome.runtime.onMessage.addListener((message) => { if (message.type === EXTENSION_CUSTOM_EVENT.TOGGLE_EXTENSION) { toggleExtensionUI(); } }
API-запросы с авторизацией
Никакой запрос с авторизацией из реакт компонентов не подхватывал куки, который мы получали после авторизации в нашем сервисе. Поэтому вся логика запросов была расположена в background файле.
React-компонент:
// Get config from API const getConfigResponse = await new Promise<{ integrationKey: string | null; error?: string; }>((resolve) => { chrome.runtime.sendMessage( { type: EXTENSION_CUSTOM_EVENT.GET_CONFIG, domain: window.location.hostname, }, (response) => resolve(response), ); });
background-файл:
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === EXTENSION_CUSTOM_EVENT.GET_CONFIG) { const { domain } = message; getConfig(domain) .then((response) => sendResponse(response)) .catch((error) => sendResponse({ integrationKey: null, error: error.message || 'Failed to get config', }), ); return true; }
Невозможность внедрить сторонний скрипт на страницу
Не все сайты позволяют внедрить к себе на страницу скрипт с другого домена, но есть способы для обхода. Здесь нам на помощь придет свойство, о котором мы говорили ранее: declarative_net_request.rule_resources. С его помощью мы можем убрать заголовки, которые запрещают нам внедрить скрипт на страницу. Делается это примерно так:
"declarative_net_request": { "rule_resources": [ { "id": "ruleset_1", "enabled": true, "path": "rules.json" } ] },
Файл rules.json:
{ { "id": 3, "priority": 1, "action": { "type": "modifyHeaders", "responseHeaders": [ { "header": "content-security-policy", "operation": "remove" } ] }, "condition": { "urlFilter": "*your-site-for-inject.com*", "resourceTypes": ["main_frame", "sub_frame"] } }
Отказ в публикации из-за использования скрипта с другого домена
С этим мы тоже столкнулись. Chrome Web Store отказывался публиковать расширение, так как не контролирует скрипт, который внедряет наш задеплоенный виджет, и не может позволить себе такие риски. Решение простое — мы продублировали код для инжекта нашего виджета в папку с расширением:

Из минусов — нужно не забыть обновить этот файл в репозитории с расширением, если скрипт обновится.
Заключение
Надеюсь, эта статья помогла вам разобраться с неочевидными нюансами разработки браузерных расширений. Я постарался осветить все составляющие этого процесса: и способы встраивания от попапов до отдельных окон, и доступы, и особенности Google Store.
Если где-то остались вопросы или вы знаете другие полезные штуки, которые я не раскрыл — пишите в комментарии, обсудим. А если вам интересно почитать еще о том, как мы пилим фронтенд, то вот целая пачка статей из нашего блога:
