javascript

От клика до железа: хроника одного запроса. Часть 2

  • вторник, 18 августа 2026 г. в 00:00:02
https://habr.com/ru/companies/nspk/articles/1067156/

Введение

В первой части мы заставили кнопку на веб-странице запустить локальный exe-файл. Обычная страница не может запускать программы на компьютере напрямую. Поэтому между ними появилось расширение Chrome, а локальная программа была зарегистрирована как Native Messaging Host.

Native Messaging представляет собой механизм обмена сообщениями между расширением и локальной программой. Chrome запускает эту программу как отдельный процесс и связывает её с расширением через стандартные потоки ввода и вывода. В первой версии расширение отправляло одну команду методом sendNativeMessage(), а программа возвращала один ответ.

Теперь задача будет длиться десять секунд, регулярно сообщать прогресс и поддерживать отмену. Для такого обмена одного ответа недостаточно. Расширению и хосту нужен канал, по которому обе стороны могут отправлять несколько сообщений.

В этой части я заменю sendNativeMessage() постоянным соединением connectNative() и перепишу хост на Java 21. Мы последовательно разберём путь команды, формат сообщений, восстановление соединения после сбоя и завершение процесса. Затем соберём хост в exe-файл с помощью GraalVM Native Image и проверим всю цепочку в настоящем Chrome.

Оборудование по-прежнему симулируется. Планировщик обновляет прогресс каждые 500 мс. Этого достаточно, чтобы увидеть сообщения от хоста, не подключая настоящее устройство и его SDK. SDK представляет набор библиотек для работы с оборудованием.

Для чтения понадобится базовое знакомство с JavaScript, Promise и Java. Устройство расширений Chrome и формат Native Messaging будут разобраны по ходу статьи.

Маршрут статьи:

Где заканчиваются возможности sendNativeMessage()

sendNativeMessage() отправляет хосту один JSON-объект и ожидает один JSON-ответ. Для каждого вызова Chrome запускает новый процесс хоста. Процесс представляет собой отдельный экземпляр работающей программы со своей памятью.

Первое сообщение от процесса Chrome возвращает расширению как ответ, а последующие сообщения игнорирует. Эта модель описана в официальной документации Native Messaging.

Такой способ подходит для независимых команд. Например, расширение может запросить серийный номер устройства. Хост запускается, читает команду, возвращает номер и завершается. Следующему запросу состояние предыдущего процесса не требуется.

Длительная операция тоже может вернуть ответ через sendNativeMessage(), если держать вызов открытым до конца. Проблема появляется, когда во время работы нужно передавать прогресс или принять отмену. Хост не может самостоятельно отправить промежуточное сообщение этому вызову.

Можно каждую секунду вызывать отдельную команду получения статуса. Такой регулярный опрос называют polling. Однако каждый вызов запустит новый процесс, а значит, новый хост не увидит состояние предыдущего. Состояние придётся перенести в файл, базу данных или другой постоянно работающий процесс.

Для Java добавляется стоимость запуска JVM, среды выполнения Java. Новый процесс должен загрузить JVM и классы приложения. Один запуск не создаёт заметной проблемы, но повторять его для каждого обновления прогресса нет смысла.

Поэтому выбор зависит от характера команды. Для независимого запроса с одним ответом подходит sendNativeMessage(). Для нескольких команд, общего состояния и событий от хоста нужен постоянный канал.

Переходим к connectNative()

chrome.runtime.connectNative(hostName) запускает Native Messaging Host и возвращает объект Port. Порт представляет открытое соединение между расширением и процессом. Пока он существует, обе стороны могут отправить любое количество сообщений.

Минимальная версия выглядит так:

const port = chrome.runtime.connectNative("dev.example.native_host");

port.onMessage.addListener((message) => handleMessage(message));
port.onDisconnect.addListener(() => handleDisconnect());
port.postMessage(request);

Вызов postMessage() отправляет объект хосту. Обработчик onMessage получает ответы и события, а onDisconnect сообщает о закрытии соединения. Вместо одного обработчика ответа у sendNativeMessage() здесь расширение само управляет соединением в течение всей сессии.

Сессией будем называть время от открытия порта до его закрытия. Расширение должно дождаться готовности хоста, связать ответы с запросами и обработать неожиданное завершение процесса.

Порт хранится в Service Worker. Это фоновый JavaScript-файл расширения, который работает отдельно от веб-страницы. В Manifest V3 Chrome может остановить Service Worker после периода бездействия.

Открытый через connectNative() порт сохраняет Service Worker активным. Это поведение указано в документации жизненного цикла.

После сбоя повторный connectNative() запускает новый процесс, а не подключается к прежнему. Новый хост создаёт новый sessionId, то есть идентификатор сессии. Старую задачу он не помнит, поэтому интерфейс сбрасывает её состояние.

Сам connectNative() недоступен контент-скрипту, который выполняется рядом с веб-страницей. Вызвать его может Service Worker или страница расширения. Поэтому команда проходит через несколько компонентов.

Архитектура нового решения

Схема читается слева направо. Сплошные линии показывают обмен сообщениями, а пунктирные линии связывают манифесты с компонентами, которые они настраивают.

Архитектура решения
Архитектура решения

Рис. 1. Команда проходит через страницу, контент-скрипт и Service Worker, после чего попадает в Java-хост.

Компонент

Где работает

Задача

Веб-страница

Обычная вкладка Chrome

Показывает кнопки, прогресс и результат

Программный интерфейс страницы, или API

В JavaScript-контексте страницы

Предоставляет методы startTask() и cancelTask()

Контент-скрипт

Вкладка, но с правами расширения

Передаёт проверенные сообщения между страницей и расширением

Service Worker

В фоне расширения

Владеет портом, хранит ожидающие ответы и восстанавливает соединение

Манифест Native Messaging

Файл на диске

Связывает имя хоста с exe-файлом и разрешённым расширением

Java-хост

Отдельный процесс Windows

Читает команды, проверяет протокол и отправляет ответы и события

Сервис задачи

Внутри Java-хоста

Симулирует длительную операцию и вычисляет прогресс

При запуске задачи сообщение идёт от страницы вправо до Java-хоста. Хост читает его из стандартного ввода. Ответ возвращается по тому же пути. События прогресса тоже идут справа налево, но хост создаёт их самостоятельно, без нового запроса страницы.

Такое количество слоёв требуется из-за границ браузера. Обычная страница не получает доступ к chrome.runtime. Контент-скрипт не может вызвать connectNative(). Service Worker может открыть нативный порт, но не рисует интерфейс вкладки. Каждый компонент выполняет только доступную ему часть работы.

Проектируем протокол сообщений

Протокол определяет, какие сообщения могут отправлять стороны и что означает каждое поле. Без такого соглашения JavaScript и Java будут обмениваться JSON, но не смогут одинаково трактовать его содержимое.

В первой версии было достаточно объекта { "text": "hello" }. Теперь по одному соединению проходят три вида сообщений:

  • запрос содержит команду от расширения;

  • ответ сообщает результат конкретного запроса;

  • событие сообщает об изменении, которое хост обнаружил самостоятельно.

Все сообщения имеют общую верхнюю структуру. В коде она называется envelope, то есть обёртка сообщения. Поле type определяет вид обёртки, а protocolVersion позволяет обеим сторонам проверить совместимость формата.

Поле

Где используется

Назначение

protocolVersion

везде

Позволяет отклонить несовместимую версию

type

везде

Различает request, response и event

requestId

запрос и ответ

Связывает ответ с конкретным вызовом

command

запрос

Содержит имя команды

ok

ответ

Показывает, читать payload или error

event

событие

Содержит имя события

payload

данные сообщения

Передаёт параметры или состояние задачи

Запрос на запуск задачи выглядит так:

{
  "protocolVersion": 1,
  "type": "request",
  "requestId": "5b31282c-49e2-4c15-a28d-fdabce4bb3bd",
  "command": "task.start",
  "payload": {}
}

requestId содержит UUID, практически уникальную строку вида 5b31282c-.... API страницы создаёт новый идентификатор для каждой команды. Service Worker сохраняет под ним ожидающий Promise. Когда приходит ответ с тем же requestId, Service Worker находит нужный Promise и завершает его.

Это нужно даже при одном порте. Например, страница может почти одновременно запросить статус и отправить ping. Ответы не обязаны прийти в порядке запросов, поэтому связывать их только по очереди нельзя.

Успешный ответ сохраняет идентификатор запроса:

{
  "protocolVersion": 1,
  "type": "response",
  "requestId": "5b31282c-49e2-4c15-a28d-fdabce4bb3bd",
  "ok": true,
  "payload": {
    "taskId": "f1b1a84f-b79f-4cc3-b573-da75c87b55cb",
    "state": "running",
    "progress": 0,
    "sessionId": "9693b0e7-1791-46e5-81d7-21777236996a"
  }
}

Если команда выполнена, ok имеет значение true, а данные результата находятся в payload. Поле payload содержит полезную нагрузку сообщения, то есть данные, ради которых оно отправлено. При ошибке ok равен false, и клиент читает объект error:

{
  "protocolVersion": 1,
  "type": "response",
  "requestId": "5b31282c-49e2-4c15-a28d-fdabce4bb3bd",
  "ok": false,
  "error": {
    "code": "TASK_ALREADY_RUNNING",
    "message": "A task is already running",
    "retryable": false
  }
}

code предназначен для программы. По нему интерфейс может выбрать дальнейшее действие. Поле message содержит текст для журнала и диагностики. retryable отвечает на вопрос, может ли повтор команды без других изменений дать новый результат.

В текущем примере все ошибки постоянные, поэтому retryable всегда равен false. У реального устройства временной ошибкой могло бы быть короткое отсутствие связи.

Событие не является ответом на конкретную команду, поэтому у него нет requestId. Вместо command оно содержит имя события:

{
  "protocolVersion": 1,
  "type": "event",
  "event": "task.progress",
  "payload": {
    "taskId": "f1b1a84f-b79f-4cc3-b573-da75c87b55cb",
    "state": "running",
    "progress": 35,
    "sessionId": "9693b0e7-1791-46e5-81d7-21777236996a"
  }
}

Прогресс показывает главное отличие постоянного порта: хост может отправить данные без нового запроса расширения. Симулятор делит десятисекундную задачу на 20 шагов по 500 мс и после каждого шага создаёт событие. Для реального устройства процент должен приходить из его SDK или вычисляться по правилам конкретной операции.

В примере четыре команды:

Команда

Назначение

host.ping

Проверить процесс и получить sessionId с отметкой времени

task.start

Запустить единственную активную задачу

task.status

Получить текущее состояние задачи

task.cancel

Отменить выполняющуюся задачу

Сервис допускает только одну активную задачу. Повторный task.start во время выполнения возвращает TASK_ALREADY_RUNNING, а task.cancel без активной задачи возвращает TASK_NOT_RUNNING. Это правило нашего примера, а не ограничение Native Messaging. Оно не позволяет двум задачам одновременно управлять одним устройством.

Команда task.status нужна, чтобы страница могла запросить текущее состояние после открытия или пропущенного события.

Хост также отправляет пять событий: host.ready, host.heartbeat, task.progress, task.completed и task.cancelled. Событие host.ready подтверждает, что процесс запустился и начал читать сообщения. Объект Port появляется раньше, поэтому само его создание ещё не подтверждает готовность Java-кода.

Как Chrome отделяет сообщения

Потоки ввода и вывода передают последовательность байтов без границ между сообщениями. Поэтому Chrome не может определить конец JSON по фигурной скобке или переводу строки. Перед каждым JSON передаётся четырёхбайтовый заголовок с его размером:

[ 4 байта: размер N ][ N байт: JSON в UTF-8 ]

Такой формат отделяет одно сообщение от следующего с помощью заголовка длины. Сначала получатель читает ровно четыре байта, узнаёт длину, а затем читает указанное количество байтов JSON.

Размер считается по байтам, а не по количеству символов Java или JavaScript. Например, русская буква занимает в UTF-8 больше одного байта. Число в заголовке записывается в порядке байтов текущей платформы. На Windows x64 используется порядок little-endian: сначала идёт младший байт числа.

Для двух направлений действуют разные ограничения. Сообщение от хоста к Chrome не должно превышать 1 MiB, то есть 1 048 576 байт. Chrome может отправить хосту до 64 MiB. Эти значения и формат заголовка приведены в документации Chrome. В коде лимиты хранятся в одном месте:

public static final int MAX_INBOUND_BYTES = 64 * 1024 * 1024;
public static final int MAX_OUTBOUND_BYTES = 1024 * 1024;

Хост проверяет размер до создания массива для сообщения. Иначе четыре случайных байта можно принять за очень большое число и попытаться выделить под него память.

Что именно проверяем

После чтения байтов ProtocolCodec превращает JSON в запрос и выполняет проверки по порядку:

Проверка

Ошибка

Данные являются корректным JSON-объектом

MALFORMED_JSON или INVALID_MESSAGE

protocolVersion является числом и равен 1

INVALID_MESSAGE или UNSUPPORTED_VERSION

type имеет значение request

INVALID_MESSAGE

requestId содержит UUID

INVALID_MESSAGE

command содержит непустую строку

INVALID_MESSAGE

payload является JSON-объектом

INVALID_MESSAGE

Команда поддерживается хостом

UNKNOWN_COMMAND

Проверки повторяются на нескольких границах. Контент-скрипт проверяет сообщения страницы, потому что страница не считается доверенной. Service Worker ещё раз проверяет данные перед отправкой в порт. Java-хост тоже разбирает запрос самостоятельно. Нельзя считать вход безопасным только потому, что его уже проверил другой компонент.

Дорабатываем Chrome-расширение

Манифест и вечное «на время»

Манифест расширения представляет JSON-файл с его настройками и разрешениями. В первой части разрешение <all_urls> позволяло запускать контент-скрипт на любом сайте. Для рабочего расширения такой доступ слишком широк.

Теперь манифест запрашивает только разрешение nativeMessaging, которое нужно для связи с локальным хостом. Контент-скрипт запускается на двух адресах тестовой страницы:

{
  "permissions": ["nativeMessaging"],
  "content_scripts": [
    {
      "matches": [
        "http://localhost:8080/*",
        "http://127.0.0.1:8080/*"
      ],
      "js": ["content.js"],
      "run_at": "document_start"
    }
  ]
}

Поле matches перечисляет страницы, на которых Chrome загрузит контент-скрипт. В реальном продукте сюда нужно подставить адреса корпоративного сайта.

Разрешение, добавленное временно
Разрешение, добавленное временно

Для распакованного расширения поле key фиксирует постоянный ID: mcfkjnnlhdifhdccljfgknohoogopabd. Без него ID может измениться после загрузки расширения в другом профиле Chrome.

Манифест Native Messaging хранится отдельно от манифеста расширения. Он указывает имя хоста, путь к программе и список расширений, которым разрешён запуск:

{
  "name": "dev.example.native_host",
  "path": "C:\\path\\to\\native-host.exe",
  "type": "stdio",
  "allowed_origins": [
    "chrome-extension://mcfkjnnlhdifhdccljfgknohoogopabd/"
  ]
}

Адрес chrome-extension://.../ в allowed_origins содержит тот же ID. Если значения не совпадают, Chrome не запустит процесс и вернёт ошибку Access to the specified native messaging host is forbidden.

Граница window.postMessage

Веб-страница и контент-скрипт видят один DOM, то есть одно дерево HTML-элементов. При этом Chrome выполняет их JavaScript в разных контекстах. Переменная, созданная страницей, не появляется в контент-скрипте автоматически.

Для обмена используется window.postMessage(). Метод отправляет событие в текущее окно. Его могут увидеть разные скрипты страницы, поэтому контент-скрипт принимает сообщение только после нескольких проверок:

function isAllowedPageMessage(event) {
  const data = event.data;
  return (
    event.source === window &&
    ALLOWED_ORIGINS.has(event.origin) &&
    data !== null &&
    typeof data === "object" &&
    data.channel === "FROM_PAGE" &&
    (data.kind === "client.connect" ||
      (data.kind === "protocol.request" && isRequest(data.message)))
  );
}

Проверка event.source === window подтверждает, что событие пришло от текущего окна. event.origin содержит источник страницы, например http://127.0.0.1:8080. Поле channel с FROM_PAGE отделяет наши сообщения от остальных событий message.

Маркер FROM_PAGE не является секретом. Любой скрипт страницы может его скопировать. Поэтому код также проверяет источник, адрес и структуру данных. Service Worker независимо сравнивает адрес отправителя с sender.tab.url.

Chrome рекомендует считать данные от контент-скриптов недоверенными в документации по обмену сообщениями.

Promise снаружи, requestId внутри

Код, который добавляется в страницу, предоставляет ей небольшой API. Страница вызывает обычные функции и не работает с сообщениями расширения напрямую:

window.nativeHost = Object.freeze({
  connect: () => sendEnvelope("client.connect"),
  ping: () => sendCommand("host.ping"),
  startTask: () => sendCommand("task.start"),
  getTaskStatus: () => sendCommand("task.status"),
  cancelTask: () => sendCommand("task.cancel"),
  subscribe(listener) {
    subscribers.add(listener);
    return () => subscribers.delete(listener);
  },
});

Object.freeze() запрещает добавлять, удалять и заменять свойства созданного объекта. Например, присваивание window.nativeHost.startTask = null не изменит метод. Это защита от случайной модификации, а не граница безопасности: другой скрипт всё ещё может заменить всю ссылку window.nativeHost.

Каждая команда возвращает Promise, который завершится после ответа хоста. Одновременно API создаёт requestId и таймер на десять секунд. Если ответ не пришёл вовремя, Promise завершается ошибкой. Если ответ пришёл, его requestId позволяет найти правильный Promise в коллекции pending.

События не завершают Promise, потому что не относятся к одному запросу. API передаёт их функциям, зарегистрированным через subscribe(). Поэтому для интерфейса startTask() выглядит как обычная асинхронная функция, а прогресс приходит через подписку.

Владелец нативного порта

Service Worker создаёт один объект NativeConnectionManager. Он хранит нативный Port, коллекцию ожидающих ответов и состояние соединения. Возможны пять состояний: подключение, работа, повторное подключение, отключение и ошибка. В сообщениях они называются connecting, connected, reconnecting, disconnected и error.

Метод send() открывает соединение при необходимости, сохраняет Promise по requestId и отправляет запрос через порт:

send(request) {
  this.connect();
  return new Promise((resolve, reject) => {
    const timeout = this.schedule(() => {
      this.pending.delete(request.requestId);
      reject(new Error("Native host response timeout"));
    }, 10_000);

    this.pending.set(request.requestId, { resolve, reject, timeout });
    this.port.postMessage(request);
  });
}

В показанном фрагменте оставлен основной путь. Полный метод также проверяет, что порт действительно создался, и перехватывает ошибку postMessage(). Хост может завершиться между вызовом connect() и отправкой запроса, поэтому одной предварительной проверки недостаточно.

Повторное подключение без старого состояния

Когда порт закрывается, Chrome вызывает onDisconnect. Менеджер завершает ошибкой все ожидающие Promise и сообщает интерфейсу о разрыве. Затем он делает до пяти попыток открыть новое соединение.

Задержка перед попытками постепенно растёт: 250, 500, 1000, 2000 и 4000 мс. Это экспоненциальная задержка, или exponential backoff. Она не даёт программе непрерывно запускать новые процессы во время сбоя. К задержке добавляется случайный разброс до 20%, или jitter. Он уменьшает вероятность того, что несколько клиентов одновременно повторят запрос.

Источник случайных чисел передаётся в BackoffSequence снаружи. В тесте вместо случайного значения можно вернуть заранее известное число и точно проверить задержку.

После события host.ready счётчик попыток сбрасывается. Если новый хост не запустился за пять попыток, интерфейс переходит в состояние error.

Каждое успешное повторное подключение создаёт новый процесс и новый sessionId. Менеджер очищает состояние прежней задачи, потому что оно хранилось в памяти завершившегося процесса. Для настоящего продолжения операции состояние пришлось бы сохранять вне хоста и добавить отдельную команду восстановления.

Жизненный цикл соединения
Жизненный цикл соединения

Рис. 2. Первое подключение и два исхода: завершение сессии или запуск нового процесса после сбоя.

Создаём Native Messaging Host на Java

Для хоста я выбрал Java 21, потому что команда уже использует Java и будет поддерживать интеграцию с SDK оборудования. Хост остаётся обычным консольным приложением: он читает стандартный ввод, пишет ответы в стандартный вывод и не открывает собственного окна.

Проект использует несколько инструментов:

Инструмент

Для чего нужен

Gradle Wrapper

Запускает зафиксированную версию системы сборки Gradle

Jackson

Преобразует JSON в объекты Java и обратно

SLF4J

Предоставляет единый интерфейс для журналирования

JUnit 5

Запускает автоматические тесты Java-кода

Spring здесь не нужен. Объектов немного, поэтому main() создаёт их явно и передаёт необходимые зависимости через конструкторы.

Читаем сообщение целиком

Java представляет стандартный ввод объектом InputStream. Вызов read(byte[]) читает доступные байты, но не обещает заполнить весь переданный массив. Например, из четырёх байтов заголовка первый вызов может вернуть только два.

Поэтому NativeMessageReader сначала дочитывает все четыре байта длины, проверяет лимит, а затем таким же способом читает JSON:

private void readFully(
        byte[] destination,
        int offset,
        int length,
        String errorMessage) throws IOException {
    int total = 0;
    while (total < length) {
        int read = input.read(destination, offset + total, length - total);
        if (read == -1) {
            throw new EOFException(errorMessage);
        }
        total += read;
    }
}

Значение -1 от read() означает EOF, конец входного потока. Если EOF приходит до первого байта нового заголовка, Chrome закрыл соединение и хост может завершиться штатно. EOF внутри заголовка или JSON означает оборванное сообщение, поэтому NativeMessageReader возвращает ошибку.

NativeMessageWriter выполняет обратную операцию. Он преобразует JsonNode в байты UTF-8, проверяет размер и добавляет четырёхбайтовый заголовок. Вызов flush() требует немедленно передать накопленные байты в канал, а не оставлять их во внутреннем буфере Java.

Порядок байтов заголовка берётся у текущей платформы через ByteOrder.nativeOrder(). На Windows x64 это little-endian.

Метод write() объявлен synchronized, то есть в один момент его выполняет только один поток. Ответы пишет основной поток Java, а прогресс и heartbeat отправляет поток планировщика. Без синхронизации заголовок одного сообщения мог бы смешаться с JSON другого.

public synchronized void write(JsonNode message) throws IOException {
    byte[] payload = mapper.writeValueAsBytes(message);
    if (payload.length > maxMessageBytes) {
        throw new IOException("Message exceeds the configured outbound limit: " + payload.length);
    }
    byte[] header = ByteBuffer.allocate(Integer.BYTES)
            .order(ByteOrder.nativeOrder())
            .putInt(payload.length)
            .array();
    output.write(header);
    output.write(payload);
    output.flush();
}

Отделяем чтение от команд

Код разделён по ответственности. NativeMessageReader и NativeMessageWriter работают только с байтами и не знают о командах. ProtocolCodec разбирает JSON и проверяет поля, но не читает стандартный ввод. ProtocolHandler выполняет команды, но не знает о четырёхбайтовом заголовке.

Такое разделение упрощает тесты. Формат сообщения можно проверить на массиве байтов, а проверку команды task.start не нужно связывать с настоящим процессом Chrome.

Команду выбирает обычное выражение switch:

JsonNode payload = switch (request.command()) {
    case "host.ping" -> pingPayload();
    case "task.start" -> taskPayload(tasks.start());
    case "task.status" -> taskPayload(tasks.status());
    case "task.cancel" -> taskPayload(tasks.cancel());
    default -> throw new ProtocolException(
            "UNKNOWN_COMMAND",
            "Unknown command: " + request.command(),
            false,
            request.requestId());
};

TaskService хранит снимок текущего состояния с полями taskId, state и progress. Снимок неизменяемый: при обновлении сервис создаёт новый объект вместо изменения старого. Одновременно может выполняться одна задача. Повторный task.start возвращает TASK_ALREADY_RUNNING, а отмена без активной задачи возвращает TASK_NOT_RUNNING.

Длительность, интервал обновления, планировщик и генератор ID передаются в конструктор. Рабочая конфигурация использует десять секунд, интервал 500 мс и случайный UUID. Тест передаёт другой планировщик и заранее известный ID. Благодаря этому он сам переводит задачу на следующий шаг и не ждёт реальные десять секунд.

От команды к событию

После task.start хост сразу возвращает ответ со состоянием running. Этот ответ сообщает, что команда принята. Затем планировщик вызывает advance() каждые 500 мс. Сервис обновляет процент и отправляет отдельное событие task.progress. При достижении 100% планировщик останавливается, а хост отправляет task.completed.

Последовательность выполнения задачи
Последовательность выполнения задачи

Рис. 3. Ответ на task.start и последующие события прогресса проходят через один порт.

Тот же планировщик раз в пять секунд отправляет host.heartbeat с sessionId и отметкой времени. Heartbeat представляет периодическое служебное сообщение, которое показывает, что процесс ещё может писать в канал. Оно не подтверждает работу реального устройства, потому что его SDK в этой проверке не участвует.

Управляем жизненным циклом хоста

Жизнь процесса привязана к порту, который открыл Chrome. Пока порт существует, Chrome держит стандартный ввод хоста открытым. Закрытие порта приводит к концу входного потока.

При запуске NativeHostRuntime отправляет host.ready, запускает heartbeat и входит в цикл чтения:

public void run() throws IOException {
    writer.write(messages.event("host.ready", handler.sessionPayload()));
    heartbeat.start();
    while (true) {
        var message = reader.read();
        if (message.isEmpty()) {
            LOGGER.info("Chrome closed stdin; shutting down native host");
            return;
        }
        handleMessage(message.get());
    }
}

Когда закрывается последняя клиентская вкладка, расширение разрывает порт. Chrome закрывает стандартный ввод процесса. Метод read() возвращает пустой Optional, которым код обозначает штатный EOF, и цикл завершается.

Ресурсы хоста созданы внутри try-with-resources. Эта конструкция Java автоматически вызывает их close() при выходе из блока, в том числе после ошибки. Закрытие останавливает heartbeat, отменяет активную задачу и завершает планировщик. Отдельно наблюдать за процессом Chrome не требуется: конец ввода уже сообщает о закрытии соединения.

При аварийном завершении Chrome входной канал закрывается так же. Если первым завершается хост, Service Worker получает Port.onDisconnect. Поэтому дополнительный локальный порт или сокет для обнаружения разрыва не нужен.

stdout: часть протокола

В Native Messaging стандартный вывод stdout не предназначен для обычного текста. Команда System.out.println("started") запишет туда символы без четырёхбайтового заголовка. Chrome примет первые четыре байта слова за размер сообщения, после чего разбор канала завершится ошибкой.

Поэтому в stdout пишет только NativeMessageWriter. Для журналов используется отдельный стандартный поток ошибок stderr. SLF4J направляет туда сообщения, трассировки стека и временную отладочную печать.

Если канал закрылся во время отправки события, NativeMessageWriter получает IOException. Ошибка записывается в stderr, а при закрытии хоста все запланированные вызовы отменяются.

Регистрация без прав администратора

На Windows Chrome ищет путь к манифесту Native Messaging в реестре. Для установки только текущему пользователю используется раздел HKEY_CURRENT_USER, сокращённо HKCU:

HKCU\Software\Google\Chrome\NativeMessagingHosts\dev.example.native_host

Значение ключа содержит полный путь к JSON-манифесту. Скрипт Register-NativeHost.ps1 создаёт файл, подставляет путь к программе и записывает JSON в UTF-8 без BOM. BOM представляет дополнительные байты в начале текстового файла; здесь они не нужны. Поскольку используется HKCU, права администратора не требуются.

Перед E2E-тестом скрипт сохраняет прежнее значение ключа и его тип. Блок finally выполняется и после успешного теста, и после ошибки. В нём скрипт восстанавливает сохранённые данные. Если ключа раньше не было, удаляется только ключ нашего хоста. Остальные зарегистрированные приложения не затрагиваются.

Боремся с особенностями JVM

Обычное Java-приложение запускается внутри JVM. JVM загружает байт-код из JAR-файлов и выполняет его на текущей операционной системе. Сначала я проверил хост именно в таком режиме, без Native Image. Так ошибки протокола и потоков не смешиваются с ошибками нативной сборки.

Задача Gradle :host:installDist собирает сценарий запуска и все JAR-зависимости. Интеграционный тест запускает получившийся JVM-процесс, получает host.ready, отправляет host.ping, проверяет ответ и закрывает стандартный ввод. В отличие от модульного теста одного класса, здесь проверяется собранное приложение вместе с кодом main().

После успешной проверки JVM-варианта я добавил GraalVM Native Build Tools. Этот плагин запускает Native Image, который заранее компилирует Java-приложение в машинный код и создаёт exe-файл:

graalvmNative {
    binaries {
        named("main") {
            imageName.set("native-host")
            mainClass.set(application.mainClass)
            buildArgs.add("--no-fallback")
            buildArgs.add("-H:+ReportExceptionStackTraces")
        }
    }
    metadataRepository {
        enabled.set(true)
    }
}

imageName задаёт имя файла native-host.exe, а mainClass указывает класс с методом main(). Флаг --no-fallback требует собрать самостоятельную программу, которой не нужна внешняя JVM.

Предварительная компиляция не всегда видит классы и ресурсы, которые библиотека выбирает только во время работы. GraalVM Reachability Metadata Repository хранит необходимую конфигурацию для таких библиотек. Плагин ищет подходящие данные для зависимостей проекта.

Этот механизм описан в документации Native Build Tools.

В этом проекте Jackson работает с JsonNode, а данных из репозитория метаданных оказалось достаточно. Файл ручной конфигурации reflect-config.json не понадобился. Это результат текущей конфигурации, а не обещание для любого проекта с Jackson.

На Windows Native Image использует нативный компилятор из Visual Studio 2022 Build Tools и Windows SDK. Набор компилятора, линкера и системных библиотек называют цепочкой инструментов, или toolchain. Если сборка не находит vcvarsall.bat, нужно проверить установку C++ Build Tools, а не настройки Gradle.

Что и как измерялось

Одного запуска недостаточно для сравнения JVM и Native Image. На время влияют файловый кеш, антивирус и фоновые процессы. Поэтому сначала выполняются запуски для прогрева, а затем отдельная серия измерений.

Скрипт scripts/benchmark.mjs для каждого варианта выполняет пять прогревочных и 25 измеряемых запусков. Время считается от создания процесса до получения полного сообщения host.ready.

Потребление памяти берётся из свойства Windows PeakWorkingSet64. Оно показывает максимальный объём физической памяти, который процесс использовал к моменту чтения значения.

Для JVM-варианта размером артефакта считается каталог со сценарием запуска и JAR-файлами без JDK. Для Native Image измеряется один exe-файл. Последний столбец не показывает размер полной поставки с установленной JVM.

Стенд измерения:

  • ОС: Windows_NT 10.0.26200 x64;

  • CPU: AMD Ryzen 9 4900HS with Radeon Graphics, 16 логических ядер;

  • RAM: 31,42 GiB;

  • Java/GraalVM: Oracle GraalVM 21.0.12+7.1;

  • прогревочных запусков: 5;

  • измеряемых запусков: 25.

В таблице приведены медиана и p95. Медиана делит результаты пополам: половина запусков быстрее неё, половина медленнее. p95 показывает границу, в которую уложились 95% запусков.

Результаты этой машины:

Вариант

Медиана запуска

p95 запуска

Медиана памяти

p95 памяти

Размер артефакта

JVM

1035,11 мс

1100,31 мс

174,48 MiB

177,09 MiB

2,34 MiB

Native Image

90,99 мс

100,84 мс

15,83 MiB

15,89 MiB

31,34 MiB

Эти числа относятся только к указанной машине и этому небольшому хосту. Они позволяют сравнить два варианта одной программы в одинаковых условиях, но не описывают производительность всех Java-приложений.

При connectNative() хост запускается один раз на сессию, поэтому разница старта не повторяется для каждой команды. Выбирать Native Image только по первой колонке не следует. Для этого проекта важно и другое свойство: пользователю можно передать один exe-файл без отдельной установки JDK.

Обновляем пользовательский интерфейс

Страница состоит из одной колонки со стандартными элементами браузера. В верхней части видны состояние соединения, sessionId и состояние задачи. Ниже находятся полоса прогресса, кнопки запуска и отмены и результат. Журнал событий скрыт в раскрывающемся блоке, чтобы не занимать половину экрана.

Работающий интерфейс
Работающий интерфейс

Рис. 4. Интерфейс после завершения тестовой задачи.

Кнопки зависят от состояния задачи. При running, то есть во время выполнения, запуск отключён, а отмена доступна. В состояниях completed и cancelled, после завершения или отмены, запуск снова становится доступен. При повторном подключении reconnecting задача сбрасывается в начальное состояние idle, а прогресс возвращается к нулю.

В журнале можно увидеть host.ready, heartbeat, прогресс, разрыв и повторное подключение. По этим записям видно, что хост отправляет события без нового запроса страницы.

Полученные строки записываются в DOM через textContent, а не через innerHTML. Поэтому браузер показывает диагностическое сообщение как текст и не пытается выполнить содержащуюся в нём HTML-разметку.

Собираем и проверяем всю цепочку

У проекта одна полная команда:

npm run verify

Команда последовательно проверяет JavaScript и Java, собирает JVM-дистрибутив и Native Image, а затем запускает E2E-тест. Локальный Gradle устанавливать не нужно: Gradle рекомендует выполнять сборку через Wrapper, который фиксирует версию Gradle для проекта.

Модульные и интеграционные тесты

Модульные тесты проверяют отдельные классы без запуска Chrome. Интеграционные тесты соединяют несколько частей и запускают настоящий процесс. Java-тесты покрывают следующие случаи:

  • чтение полного, разбитого на части и оборванного сообщения;

  • нулевой размер и превышение лимита;

  • запись исходящего сообщения;

  • некорректный JSON, версию протокола и обязательные поля;

  • команды запуска, получения статуса и отмены;

  • прогресс и завершение через управляемый планировщик;

  • heartbeat и повторное закрытие ресурсов;

  • EOF и освобождение ресурсов NativeHostRuntime;

  • обмен с настоящим JVM-процессом через стандартный ввод и вывод.

JavaScript-тесты проверяют структуру сообщений, задержки повторного подключения, связь ответа с requestId, тайм-аут, разрыв порта, исчерпание пяти попыток и отбрасывание некорректных данных страницы.

Checkstyle проверяет оформление Java-кода, а SpotBugs ищет известные подозрительные конструкции. JaCoCo измеряет, какая часть Java-кода выполнилась во время тестов. Сборка требует покрытия не менее 80% строк и 70% ветвей условий.

Для JavaScript действуют те же пороги через Vitest и V8 coverage. Команда tsc --checkJs проверяет типы, описанные в JSDoc. Код остаётся обычным JavaScript, но ошибка в структуре объекта обнаруживается до запуска браузера.

Фактический итог прогона:

Контур

Результат

JUnit

33 теста, все прошли

JaCoCo

строки 81,47%, ветви 80,60%

Vitest

13 тестов, все прошли

V8 coverage

строки 80,34%, ветви 82,66%

Checkstyle / SpotBugs

замечаний нет

ESLint / Prettier / checkJs

проверки прошли

npm audit

уязвимостей найдено: 0

E2E в настоящем Chrome for Testing

E2E означает проверку всей цепочки от пользовательского интерфейса до конечной программы. В модульных JavaScript-тестах объект chrome.runtime.Port заменён имитацией, или mock-объектом. Имитация не проверяет манифест, ID расширения, реестр Windows, запуск exe-файла и настоящий Service Worker.

Поэтому Playwright открывает Chrome for Testing с временным профилем и загружает распакованное расширение. Используется поставляемый Playwright канал chromium, потому что обычные сборки Chrome и Edge больше не принимают необходимые флаги загрузки расширения.

E2E выполняет пять сценариев:

  1. запускает задачу, получает промежуточные значения прогресса и дожидается 100%;

  2. запускает и отменяет задачу;

  3. дважды отправляет task.start и проверяет ошибку TASK_ALREADY_RUNNING;

  4. отправляет со страницы некорректное сообщение и проверяет, что контент-скрипт его отбрасывает;

  5. завершает native-host.exe во время задачи и проверяет состояния reconnecting, новый sessionId и сброс задачи в idle.

В этой проверке одновременно участвуют HTTP-страница, каталог расширения, Service Worker, реестр Windows, Chrome Native Messaging и программа, собранная с Native Image. После теста временный профиль Chrome удаляется, а значение ключа реестра восстанавливается в finally.

Итог Playwright: 5 из 5 сценариев прошли за 20,2 секунды.

Как повторить

Устанавливаем необходимые инструменты

Пример рассчитан на Windows 10 или 11 x64. Все команды ниже нужно выполнять в PowerShell из корня репозитория.

Понадобятся следующие инструменты:

Инструмент

Для чего нужен

GraalVM JDK 21

компиляция Java-хоста и сборка исполняемого файла через Native Image

Visual Studio 2022 Build Tools версии 17.1 или новее

C+±компилятор и Windows SDK, которые использует Native Image

Node.js 20.19+ в ветке 20.x, 22.13+ в ветке 22.x или 24+

установка JavaScript-зависимостей и запуск проверок

PowerShell 5.1 или новее

регистрация и удаление Native Messaging Host

Google Chrome

ручная проверка расширения в браузере

Скачайте архив GraalVM JDK 21 для Windows x64, распакуйте его, например в C:\Tools\graalvm-jdk-21, и укажите этот каталог в JAVA_HOME. Переменные ниже действуют только в текущем окне PowerShell:

$env:JAVA_HOME = 'C:\Tools\graalvm-jdk-21'
$env:Path = "$env:JAVA_HOME\bin;$env:Path"

java -version
native-image.cmd --version

В установщике Visual Studio Build Tools выберите рабочую нагрузку «Разработка классических приложений на C++» (Desktop development with C++). Убедитесь, что вместе с ней отмечены компилятор MSVC для x64/x86 и Windows SDK. Отдельно устанавливать Gradle не нужно: в репозитории уже есть Gradle Wrapper.

Проверьте оставшиеся версии:

node --version
npm --version
$PSVersionTable.PSVersion

Запускаем полную проверку

Сначала установите зафиксированные в проекте зависимости и браузер для Playwright. Этот браузер нужен автоматическим тестам и устанавливается отдельно от Google Chrome:

npm ci
npm exec playwright install chromium
npm run verify

Команда npm run verify собирает Java-хост, запускает тесты и проверяет сценарий в браузере. Если она завершилась без ошибок, окружение готово.

Запускаем вручную

Соберите нативный хост, зарегистрируйте его для текущего пользователя Windows и запустите локальный веб-сервер:

.\gradlew.bat :host:nativeCompile
.\scripts\Register-NativeHost.ps1
node .\scripts\serve-test.mjs

Затем откройте chrome://extensions, включите режим разработчика и выберите «Загрузить распакованное расширение». Укажите каталог extension, после чего откройте http://127.0.0.1:8080.

После эксперимента остановите веб-сервер сочетанием Ctrl+C и удалите регистрацию хоста:

.\scripts\Unregister-NativeHost.ps1

Полный код, README с диагностикой, тесты и benchmark-скрипт находятся в ветке part-2 репозитория примера.

Итоги

В этой статье мы превратили одиночный вызов Java-хоста в полноценный двусторонний канал. Расширение научилось держать порт connectNative(), связывать ответы с запросами, получать события прогресса, отменять задачу и восстанавливать соединение после сбоя. Java-хост теперь обрабатывает несколько сообщений за один запуск, корректно завершает фоновые ресурсы и собирается через GraalVM Native Image. В конце мы подключили интерфейс и проверили всю цепочку настоящим E2E-тестом.

Главный результат здесь не отдельный метод или класс, а работающая система целиком. Задача получилась комплексной: в ней одновременно участвуют страница, контент-скрипт, Service Worker, протокол Native Messaging, реестр Windows, Java-процесс, нативная сборка и пользовательский интерфейс. Ошибка на одном уровне легко выглядит как проблема на другом, поэтому недостаточно проверить каждый компонент отдельно. Нужно собрать цепочку и пройти её от клика до ответа хоста.

Отладка цепочки Native Messaging
Отладка цепочки Native Messaging

Объяснить всё это было непросто, если вы добрались до этой строки, то вы молодец. Серьёзно. Вы пережили устройство расширения, бинарный заголовок, жизненный цикл Service Worker, особенности stdout, реестр Windows и сборку Native Image. Теперь вы знаете: слово «просто» во фразе «просто вызовем Java из браузера» занимает шесть букв, а всё остальное - примерно целый лонгрид.