
Всем привет! Меня зовут Александр, я продуктовый инженер в 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 eventsexport 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.
Если где-то остались вопросы или вы знаете другие полезные штуки, которые я не раскрыл — пишите в комментарии, обсудим. А если вам интересно почитать еще о том, как мы пилим фронтенд, то вот целая пачка статей из нашего блога:
ссылка на оригинал статьи https://habr.com/ru/articles/1050500/