Embed Protocol
Встраивание опросов: покажите опрос внутри своего сайта или приложения и управляйте им из кода
Embed Protocol v1
Embed Protocol v1 — обмен сообщениями между встроенным опросом и тем, во что он встроен. Опрос показывается прямо внутри вашей страницы или прямо внутри вашего приложения, при этом сообщает продукту, что происходит: открылся, человек начал, ответил, отправил, завершил. А продукт может им управлять — закрыть, перезагрузить, перехватить концовку и показать свой экран.
Один протокол обслуживает три транспорта: iframe на сайте,
WKWebView на iOS и
WebView на Android.
Сам опрос при этом один и тот же — та же ссылка, те же вопросы, та же логика, те же ответы в кабинете.
Сначала главное: возможно, писать хост не нужно
Для сайта у встраивания есть готовый скрипт — он берётся в кабинете и этот протокол уже умеет: сам создаёт фрейм, сам здоровается на каждую загрузку и раздаёт события подписчикам. Свой хост нужен только тем, кому нужна своя шторка, своя аналитика или своя концовка.
Для приложения готового пакета нет — ни под iOS, ни под Android, ни под React Native. Нативный хост пишется по этому контракту: рецепты транспортов и минимальные примеры ниже.
Содержание
- Что даёт протокол и кому он нужен
- Где протокол включается
- Адрес опроса: главное правило
- Сайт: iframe и postMessage
- Приложение: iOS, Android, React Native
- Рукопожатие и жизненный цикл
- Права (capabilities)
- Конверт сообщения
- События опроса — все 15
- Команды опросу — все 4
- Что хост узнаёт, а что не узнаёт никогда
- Что обязан проверять хост
- Тестовый режим
- Отладка: порядок и молчаливые поломки
- Чего в версии 1 нет
- Где брать результаты
1. Что даёт протокол и кому он нужен
Опрос и без протокола работает в iframe: вставили —
показывается, человек отвечает, ответы приходят в кабинет. Протокол добавляет к этому две вещи, которых у плоского
фрейма нет вовсе.
| Появляется | Зачем это нужно на практике |
|---|---|
| Продукт слышит опрос 15 событий |
Отметить в своей аналитике, что опрос открылся и завершился. Показать свой индикатор заполнения. Узнать, что человека отсеяли, что ему нужна авторизация, что он платит внутри опроса. |
| Продукт управляет опросом 4 команды |
Закрыть шторку, не потеряв недопечатанный ответ. Перехватить концовку: вместо ухода опроса на «Спасибо» показать свой экран. Проверить, что опрос ещё отвечает. |
Главная проблема, которую это снимает, — концовка в приложении. Без протокола после отправки
ответов WebView уезжает с опроса на страницу
благодарности прямо внутри вашего интерфейса. С протоколом опрос отдаёт этот переход вам
(survey.navigate-request), и приложение решает
само, что показать дальше.
Старые встраивания не ломаются, но новый трафик видят
Для респондента не меняется ничего: опрос показывается, ответы уходят, концовку выполняет он сам, пока хост её не забрал. Перевставлять код не нужно, права никому не выдаются сами.
Но сказать надо прямо: значение по умолчанию перевёрнуто, и существующие встраивания
теперь получают сообщения, которых не просили — маячок
survey.handshake, а за ним факты про сам опрос
(survey.ready,
survey.error,
survey.blocked с причиной про опрос). Уходят они
прямому родителю сразу, ещё до приветствия. Ничего про респондента в них нет — это и делает
рассылку безопасной, — но если на вашей странице уже висит чужой слушатель
message, он эти сообщения увидит и должен уметь их
отличать.
Одно легаси-сообщение уходит вне протокола
При каждой успешной отправке ответа опрос по-прежнему шлёт старое сообщение
{ webask: true, action: 'answer' } — на него настроены
существующие встраивания, и снимать его не будут. Если вы обрабатываете и его, и
survey.submitted, отправка посчитается дважды.
Отличать по ключу: у протокола он __webask,
у легаси — webask.
Если вам хватает готового скрипта
Код встраивания берётся в кабинете: там же выбирается вид (врезка в страницу, окно поверх, круглая кнопка в углу, вкладка у края), условие показа и частота повторов. Рукопожатие делает сам скрипт — вам остаётся объявить подписку, и права он выведет из того, на что вы подписались.
<script>
WebAsk.q = WebAsk.q || [];
WebAsk.q.push(['ID_ОПРОСА', 'completed', function (payload, meta) {
/* payload.reason — 'finish' или 'screenout' */
}]);
</script>
Порядок относительно кода встраивания не важен: очередь работает и до загрузки скрипта, и после. Имя подписки — это
имя события протокола без префикса survey.:
ready,
started,
page-changed,
submitted,
completed и так далее — а
payload в колбэке тот же, что в
таблице событий ниже. Права скрипт выводит из
набора ваших подписок, поэтому отдельно объявлять их не нужно: подписались на
question-answered — получите
answers.
Не ставьте оба пути на один фрейм. Свой хост рядом с кабинетным скриптом означает два приветствия на один документ, а второе опрос игнорирует целиком — часть подписок молча не сработает. Либо готовый скрипт, либо свой хост; всё остальное в этом разделе — про свой.
Поддержка браузеров и WebView
| Уровень | Версии |
|---|---|
| Полная поддержка | iOS / Safari 14.5+, Chrome 84+, Firefox 78+, Edge 79+ |
| Прохождение гарантировано, отличия косметические | iOS 12.2+ (Safari 12.1+), Chrome и Android WebView 79+ |
| Ниже | респондент видит статический экран «Браузер устарел» |
На устаревшем движке опрос не монтируется, поэтому маячок
survey.handshake и ответ на
host.ping вы получить можете, а событий прохождения
не будет вовсе. На совсем древнем не будет и маячка — там не загружается бандл. На Android границу задаёт версия
системного WebView (он обновляется отдельно от ОС), на iOS — версия системы.
2. Где протокол включается
Включать ничего не надо — ни у нас, ни в адресе
- Никакого query-параметра активация не требует. Встраивается любая существующая ссылка
опроса, как есть.
?embed=1остаётся допустимым (его ставят старые embed-коды) и ничего не значит. - Регистрация домена не требуется. Ни белого списка на нашей стороне, ни ключа, ни заявки. Веб-часть интеграции делается без нашего участия и без ожидания настроек.
- Фрейминг не запрещён. Домены, с которых отдаются опросы, не выставляют
X-Frame-Optionsи не ограничиваютframe-ancestors.
Оговорка на случай проверки руками: на маркетинговом домене продукта
X-Frame-Options стоит — но опросы оттуда не
отдаются вовсе, во фрейм он и не попадает. Проверять надо тот адрес, который вы реально встраиваете, то есть
адрес опроса.
Условия активации
Контекст разрешается один раз за жизнь документа, при старте. Нужны два условия одновременно: подходящий роут и опознанный транспорт.
| Транспорт опознаётся по | ctx.embed |
|---|---|
window.self !== window.top — мы во фрейме |
iframe |
не во фрейме и есть window.webkit.messageHandlers.webask |
ios |
не во фрейме и есть window.WebAskAndroid |
android |
| иначе | протокол выключен |
Правила 2 и 3 проверяют не операционную систему, а наличие моста с известным именем. WebView, который ни одного из этих имён не выставляет, для протокола выглядит обычной страницей: хендшейка нет, события не уходят, команды игнорируются. Практически важный случай — WebView в React Native: у него собственный канал и оба имени отсутствуют (см. раздел про приложение).
Порядок правил тоже осмысленный: во фрейме нативный мост не используется, даже если он доступен.
window.webkit.messageHandlers виден во всех фреймах
WKWebView, включая кросс-доменные, — мост во фрейме указывал бы не на того, кто встроил опрос, а на приложение,
через голову клиента.
Роуты: что можно встроить и где живёт протокол
| Роут | Встраивается | Протокол |
|---|---|---|
/:uuid — прохождение |
да, ради него всё и делается | да |
/:uuid/preview — предпросмотр |
да: это штатный тестовый режим | да, с пометкой ctx.preview |
/:uuid/edit/:editUuid — правка своего ответа |
да: опрос уезжает туда сам по кнопке «Редактировать ответ», в том же фрейме | нет |
/:uuid/preview/question/:id — предпросмотр одного вопроса |
только из редактора | нет |
/:uuid/summary|report|ai-report|answers/… |
теоретически — страницы результатов на сайте клиента | нет |
/:uuid/response/…, /print, /booking/cancel/…, / |
нет | нет |
Причина роутного гейта фактическая: и события, и обработка команд живут в коде экрана прохождения. На любом другом роуте хост получал бы маячок и ответ на пинг — и больше ничего никогда, а команда молча оседала бы в буфере навсегда. Поэтому протокол там не поднимается вовсе, и отсутствие маячка — наблюдаемый признак «здесь протокола нет», а не одна из четырёх возможных причин молчания.
Единственный практический разрыв — edit: встроенный
респондент уезжает туда внутри того же фрейма, и с этого момента хост не может ни закрыть опрос командой, ни
узнать, что происходит. В версии 1 правка ответа участником протокола не является.
Предпросмотр определяется только путём адреса, а не параметром: параметр к публичной ссылке дописывает кто угодно, и тестовым режимом — а значит и потерей ответов — распоряжался бы любой желающий.
3. Адрес опроса: главное правило
Всё, что стоит в query, — это скрытая переменная автора
Такие пары уходят бэкенду вместе с запросом опроса, лежат в данных ответа, подставляются в тексты и ссылки автора — и участвуют в подписи прохождения. Если опрос настроен опознавать респондента по адресу, полный набор переменных адреса и есть ответ на вопрос «тот же это респондент или другой». Подпись сменилась — опрос стирает ответы, таймеры, позицию и терминальный экран.
Прямое следствие: кэш-бастер в src фрейма
стирает респонденту прогресс на каждой загрузке.
?t=Date.now(),
?v=<random>, «уникальный» идентификатор сессии
хоста — каждое новое значение это новая личность. А загрузок у документа много, и опрос перезагружает себя сам.
Правила для хоста, все четыре:
- Адрес стабилен на всё прохождение и на все перезагрузки: тот же путь, тот же набор и те же значения параметров.
- Кэш-бастинг не дописывать. Обход кеша HTML у рендерера уже есть и живёт под служебным именем
_r, которое из подписи вычитается; чужое имя вычесть нечем. - Скрытые переменные ставить осознанно.
?user_id=…— штатный и единственный способ связать ответ со своим пользователем, но он же и идентичность: значение обязано быть постоянным для этого респондента. - Служебных имён не занимать. Их четыре, список закрыт и не расширяется — каждое имя здесь
навсегда отнимается у авторов опросов:
loader_bg,loader_spinner,oidc,_r. Сравнение точное, без приведения регистра.
так https://webask.io/6f2b1c8e-0000-4000-8000-000000000000?user_id=12345
не так https://webask.io/6f2b1c8e-0000-4000-8000-000000000000?user_id=12345&t=1786824064103
Так же ведут себя utm-метки, если хост пересобирает их между заходами.
4. Сайт: iframe и postMessage
На сайте опрос живёт в <iframe>, а разговор
идёт через postMessage между вашей страницей и этим
фреймом. Адрес опроса — обычный, дописывать к нему ничего не нужно.
Атрибуты фрейма
Обязательного нет ничего, но фрейм у вас, как правило, кросс-доменный, а часть браузерных фич разрешена по умолчанию только документу верхнего уровня. Рабочее значение:
<iframe src="https://webask.io/ID_ОПРОСА"
allow="fullscreen; autoplay; encrypted-media; clipboard-write; picture-in-picture; payment"
style="display:block;width:100%;height:100%;border:0"></iframe>
Отказ всегда молчаливый, и вложенные плееры своими allow-списками
его не исправят: больше, чем есть у нашего фрейма, им не выдать.
| Токен | Что без него не работает |
|---|---|
fullscreen | кнопка «на весь экран» у видео |
autoplay | автовоспроизведение, включая вложенные YouTube / Rutube / VK |
encrypted-media | защищённые видеопотоки |
clipboard-write | кнопка «скопировать код ошибки» |
payment | Apple Pay / Google Pay в оплате |
geolocation | кнопка «моё местоположение», если в опросе есть карта. Добавляется отдельно |
Если вы ставите sandbox
Ставить его не обязательно: фрейм без sandbox
работает полностью. Но каждый недоданный токен ломает что-то молча.
| Не дали токен | Что происходит |
|---|---|
allow-scripts | опрос не загрузится вовсе — это SPA |
allow-same-origin | теряется Web Storage: прогресс не переживает перезагрузку, а бэкенд видит нового респондента на каждой загрузке. Плюс документ опроса теряет адресуемое имя — все сообщения приходят с event.origin === 'null', и отправлять ему надо иначе, см. врезку ниже |
allow-popups | мертвы «Посмотреть ответ», шаринг, ссылки «в новом окне» и попап SSO |
allow-popups-to-escape-sandbox | SSO не работает даже с выданным allow-popups |
allow-forms | возможны отказы в подтверждении банка при оплате |
Если вы сами поставили sandbox без allow-same-origin: адресат только '*'
Это единственное исключение из правила «никогда '*'»,
и обойти его нельзя. 'null' — это имя
непрозрачного origin, а не адрес: postMessage(msg, 'null')
бросает SyntaxError: Invalid target origin, а отправку на
конкретный адрес браузер у такого документа молча отбрасывает. Не предусмотрели — приветствие не доедет никогда, и
встраивание останется половинчатым: факты про сам опрос идут, а события про респондента и все команды мертвы,
включая host.close, то есть недопечатанный ответ теряется.
Отправлять на '*' здесь безопасно, и это не поблажка:
в сторону опроса данных о респонденте не уходит вовсе —
host.hello везёт ваше самоописание,
у close и
reset полей нет вообще,
а ping несёт ваш же
nonce.
Ещё два следствия, которые легко пропустить. На приёме
'null' не дописывается в список адресов инсталляции, а
заменяет его: сверка со списком иначе отбросит все сообщения подряд. И
trusted-тир при этом не теряется — он считается от origin вашей страницы, а не документа опроса,
поэтому события про респондента идут как обычно.
Чем за это платите, кроме потери хранилища: origin перестаёт различать документы.
'null' придёт и от опроса, и от внешней страницы
«Спасибо», и от ссылки автора — то есть приветствие и команды доедут до того документа, который окажется во
фрейме в этот момент, включая чужой. Поэтому признак песочницы берите со своего же элемента,
а не из пришедшего сообщения — атрибут ставили вы:
frame.hasAttribute('sandbox') без
allow-same-origin.
w.postMessage(msg, origin === 'null' ? '*' : origin);
Тильда, Wix и другие конструкторы
Конструкторы обычно держат ваш HTML в своём sandbox-фрейме, и тогда к опросу ваш origin
приезжает как 'null'. Опрос там показывается и
работает, соединение устанавливается, команды исполняются, приходят факты про сам опрос. Но событий про
конкретного посетителя не будет никогда — ни начала, ни ответов, ни завершения: адресата зафиксировать
физически нечем, а рассылка «кому угодно» сообщала бы любому, что этот человек проходит этот опрос.
Следствие, которое надо учесть в сценарии: терминальный переход опрос выполнит сам, фрейм уедет на «Спасибо». Если сценарий строится на событиях, проверьте это на своей платформе заранее. Как отличить этот случай от «моё приветствие не доехало» — в разделе про отладку, шаг 5.
Минимальный веб-хост целиком
На что смотреть в этом коде:
- слушатель вешается до вставки фрейма — маячок опрос присылает сам, и пропустить его значит остаться без адреса опроса;
- адрес опроса не задан заранее: он берётся из
event.originпроверенного сообщения, поэтому переезд опроса на другой домен ничего не ломает; - приветствие уходит на каждый новый
ctx.instanceId— это и есть «на каждую загрузку документа», только без догадок о том, когда она была; - проверка входящего: сначала источник, потом разбор, и только потом доверие;
- закрытие двухшаговое, с предохранителем на случай молчания, и откладывается на время оплаты.
<div id="survey-slot" style="width:100%;height:600px"></div>
<script>
(function () {
/* Адрес как он опубликован: свой параметр в query станет скрытой переменной автора. */
var SURVEY_URL = 'https://webask.io/6f2b1c8e-0000-4000-8000-000000000000';
var slot = document.getElementById('survey-slot');
var surveyOrigin = null; /* адрес ТЕКУЩЕГО документа опроса, адресат наших команд */
var greetedInstance = null; /* ctx.instanceId, с которым мы уже поздоровались */
var closeTimer = null; /* ждём survey.closed на свой host.close */
var payWait = null; /* отложенное закрытие: ждём терминальный статус оплаты */
var paymentOpen = false; /* между survey.payment 'started' и терминальным статусом */
var frame = document.createElement('iframe');
frame.title = 'WebAsk survey';
frame.style.cssText = 'display:block;width:100%;height:100%;border:0';
/* Без этих токенов молча отваливаются оплата, копирование кода ошибки, полноэкранный
режим и автовоспроизведение видео автора. geolocation здесь намеренно НЕТ: он нужен
ровно одному виджету (карта), а лишнее разрешение кросс-доменному фрейму выдавать
незачем — добавьте его, только если в опросе есть карта. */
frame.setAttribute('allow', 'fullscreen; autoplay; encrypted-media; clipboard-write; picture-in-picture; payment');
/* Слушатель ДО вставки фрейма: опрос присылает маячок сам, как только загрузится,
и пропустить его значит остаться без адреса опроса. */
window.addEventListener('message', onMessage);
frame.src = SURVEY_URL;
slot.appendChild(frame);
/* Единственное место, где трогаем окно фрейма: у снятого фрейма contentWindow это
null, и postMessage на нём бросает TypeError прямо в обработчик клика хоста. */
function post(msg, origin) {
var w = frame.contentWindow;
if (!w) return;
/* Перевод адресата обязателен: 'null' это ИМЯ непрозрачного origin, а не адрес.
postMessage(msg, 'null') бросает SyntaxError, а отправку на конкретный адрес
браузер у такого документа молча отбрасывает. Единственный адресат фрейма в
песочнице — '*', и это единственное исключение из запрета '*': в сторону опроса
данных о респонденте не уходит вовсе. */
w.postMessage(msg, origin === 'null' ? '*' : origin);
}
/* Приветствие открывает события про респондента, объявляет права и фиксирует наш
адрес. Адрес опроса угадывать не нужно: он приезжает с его сообщениями. Это
важно, потому что опрос вправе уехать сам — на другой домен инсталляции или с
www на не-www, — и заранее составленный список там молча не сработал бы. */
function greet(origin, instanceId) {
surveyOrigin = origin;
greetedInstance = instanceId;
post({
__webask: 1, /* маркер конверта и версия протокола, обязательно ЧИСЛО */
type: 'host.hello',
payload: {
min: 1,
max: 1,
host: { sdk: 'my-site', version: '1.0.0', platform: 'web' },
/* Только то, что реально обрабатываем: 'navigate' — переход, 'results' — балл.
Второй hello в пределах документа рендерер игнорирует целиком: расширить
список потом нельзя. */
capabilities: ['navigate', 'results']
}
}, origin);
}
function onMessage(event) {
/* 1. Источник — единственное, что отличает наш опрос от соседнего фрейма или
чужого скрипта. Пустой contentWindow отдельной проверкой: null !== null это
false, и после снятия фрейма условие пропускало бы синтетическое сообщение. */
if (!frame.contentWindow || event.source !== frame.contentWindow) return;
/* 2. Разбор: на проводе JSON-СТРОКА, и только в try/catch (на странице чужой трафик). */
var msg = event.data;
if (typeof msg === 'string') {
try { msg = JSON.parse(msg); } catch (e) { return; }
}
if (!msg || typeof msg !== 'object') return;
/* Маркер — фильтр «наш трафик», а не подпись: внутри опроса исполняется JS автора. */
if (typeof msg.__webask !== 'number' || typeof msg.type !== 'string') return;
/* 3. Новый документ — новое приветствие. Сверяем instanceId, а не факт хендшейка:
опрос перезагружает себя сам (принят пароль, вернулись из SSO, сменилась
версия), и каждая загрузка обнуляет права. origin берём из event.origin —
в теле сообщения ему верить нельзя. */
var ctx = msg.ctx || {};
if (ctx.instanceId && ctx.instanceId !== greetedInstance) {
greet(event.origin, ctx.instanceId);
}
/* 4. Тестовый прогон отсекаем ДО всякой логики: события предпросмотра по форме
неотличимы от настоящих — они и обязаны быть неотличимы. */
if (ctx.preview) { console.log('[webask preview]', msg); return; }
/* replay берём из КОНВЕРТА: рукопожатие приходит дважды всегда, исход загрузки —
если случился до приветствия. Различить их больше нечем. */
handle(msg.type, msg.payload || {}, msg.replay === true);
}
function send(type, data) {
if (!surveyOrigin) return; /* адреса ещё нет или фрейм снят — слать некуда */
post({ __webask: 1, type: type, payload: data || {} }, surveyOrigin);
}
function clearTimers() {
if (closeTimer !== null) { clearTimeout(closeTimer); closeTimer = null; }
if (payWait !== null) { clearTimeout(payWait); payWait = null; }
}
function destroyFrame() {
clearTimers();
window.removeEventListener('message', onMessage); /* фрейма нет — источник не сверить */
surveyOrigin = null; /* соединение кончилось */
greetedInstance = null;
if (frame.parentNode) frame.parentNode.removeChild(frame);
}
/* Два шага: host.close фиксирует ввод ЛОКАЛЬНО (на бэкенд ничего не уходит) и
отвечает survey.closed; отказаться от команды рендерер не может. */
function beginClose() {
clearTimers();
send('host.close', {});
closeTimer = setTimeout(destroyFrame, 500); /* молчание тоже штатно */
}
window.closeSurvey = function () {
if (closeTimer !== null || payWait !== null) return; /* закрытие уже идёт */
/* Открыта оплата: снятый фрейм оборвал бы 3DS банка. Ждём терминальный статус, но
со своим сроком — протокол его не гарантирует. */
if (paymentOpen) { payWait = setTimeout(beginClose, 300000); return; }
beginClose();
};
function handle(type, p, isReplay) {
switch (type) {
case 'survey.handshake':
/* Протокол на месте, и это известно ДО приветствия. Не пришёл вовсе — дело не
в приветствии: не тот адрес, роут предпросмотра или наша же проверка выше. */
break;
case 'survey.ready':
/* Цвет — КОНТЕЙНЕРУ, не фрейму: документ опроса закрашивает свой прямоугольник
целиком, а полоса чужого цвета видна на стыке (в модалке — шторка). */
if (p.backgroundColor) slot.style.backgroundColor = p.backgroundColor;
/* Повтор безвреден для покраски, но не для неидемпотентного — его по !isReplay. */
if (!isReplay) trackOpen();
break;
case 'survey.completed':
/* p.reason: 'finish' | 'screenout'; p.result — только при 'results'.
Шторку здесь НЕ закрываем: терминальный экран автора только что появился. */
break;
case 'survey.payment':
/* 'started' | 'succeeded' | 'canceled' | 'interrupted'; права не требует. */
paymentOpen = p.status === 'started';
if (!paymentOpen && payWait !== null) beginClose(); /* отложенное закрытие — можно */
break;
case 'survey.navigate-request':
/* Следствие 'navigate': адреса в событии НЕТ, только p.reason ('thanks' |
'finish-link'). Уводим сами и за 1200 мс, иначе уведёт рендерер внутри фрейма;
display:none не годится — фрейм нужно снять или уйти со страницы. */
destroyFrame();
showOwnThanks();
break;
case 'survey.closed':
/* p.hadUnsavedProgress === true — ответы на бэкенд НЕ ушли (null = не сообщается). */
if (p.hadUnsavedProgress) warnUnsaved();
if (closeTimer !== null) destroyFrame(); /* только подтверждение на наш host.close */
break;
case 'survey.error':
/* p.code, p.status, p.exception, p.fatal; текста ошибки в протоколе нет.
Приходит и до приветствия: пустая дырка вместо фрейма хуже экрана ошибки
самого опроса, поэтому сначала свой экран с повтором. */
if (p.fatal) { destroyFrame(); showOwnError(); }
break;
/* Незнакомый type игнорируем молча: протокол растёт аддитивно. */
}
}
function showOwnThanks() { /* ваш экран «спасибо» */ }
function showOwnError() { /* ваш экран ошибки с кнопкой «Повторить» */ }
function warnUnsaved() { /* «прохождение не отправлено» — предложите продолжить */ }
function trackOpen() { /* ваш пиксель: строго один раз на загрузку */ }
})();
</script>
Чего в этом коде намеренно нет
Списка адресов, где опрос может оказаться, и обработчика
load. Ни то, ни другое больше не нужно: адрес
приезжает с маячком, а признаком новой загрузки служит
ctx.instanceId. Двум веб-хостам список всё же
обязателен — см. проверки на стороне хоста.
Обратите внимание на асимметрию: приветствие отправляется объектом, а события приходят строкой JSON — её нужно разобрать. Подробнее в разделе про конверт.
5. Приложение: iOS, Android, React Native
Готовой библиотеки для установки нет — ни под iOS, ни под Android, ни под React Native
Хост пишется по этому контракту, и это порядка 200–400 строк на площадку: транспорт, приветствие на каждую загрузку документа, разбор конверта, ответ на команды, двухшаговое закрытие. Ниже — минимальные хосты целиком: их можно скопировать в проект и править как свой код.
Отдельно обратите внимание на то, что ломается молча — без ошибки ни у вас, ни у нас: мост
зарегистрирован после загрузки адреса, приветствие отправлено один раз вместо каждой загрузки, ответ команде ушёл
с потока JavaBridge, WebView снесён сразу после
host.close, шторка «спрятана» вместо ухода на
терминальном переходе, в адресе кэш-бастер, список origin неполный,
ctx.preview не отсечён. Все они с симптомами и
лечением собраны в таблице
«Что ломается молча» — прочитайте её до того,
как начнёте, а не после.
Отвечаем на частый вопрос сразу: адрес опроса в приложении обычный, тот же, что вы дали бы человеку в браузере. Дописывать к нему ничего не надо. Но если просто открыть эту ссылку в WebView, вы получите работающий опрос и ноль сообщений: приложение не узнает ни что человек начал, ни что он завершил, а после отправки WebView уедет с опроса на страницу благодарности — внутри вашего интерфейса.
Чтобы разговор состоялся, приложение регистрирует мост — канал, по которому опрос сможет отвечать. Как только мост на месте, опрос начинает присылать сообщения сам: здесь регистрация моста и есть разрешение говорить, потому что позвать его может только код вашего приложения. Приветствие в приложении нужно не для того, чтобы сообщения пошли, а чтобы объявить права.
| Платформа | Имя канала | Чем регистрируется |
|---|---|---|
| iOS, WKWebView | webask |
обработчик сообщений в конфигурации WebView |
| Android, WebView | WebAskAndroid |
объект-интерфейс с методом receive |
| React Native | WebAskAndroid |
переходник, положенный до исполнения кода страницы |
Мост регистрируется ДО загрузки адреса
Опрос определяет обстановку один раз при старте. Если добавить обработчик после того, как адрес уже поехал, протокол останется выключенным навсегда — и никаких сообщений не будет. Причём молча.
Обратный канал: как звать команды из нативного кода
Из приложения в опрос команды уходят вызовом на странице опроса:
window.__webask.receive('<JSON-строка конверта>')
- Проверка на существование глобала обязательна в каждом вызове: на роуте без протокола его нет,
и голое обращение даёт
TypeError, который уходит в обработчик завершения и теряет команду молча. - Литерал строит JSON-сериализатор, а не конкатенация с кавычками. Ручное «заменим слэш и
апостроф» не ловит реальный случай по построению:
JSON.stringifyоставляетU+2028иU+2029сырыми, а в JS-литерале старого движка это разрыв строки, то естьSyntaxErrorвместо доставки команды. На Android —JSONObject.quoteплюс отдельная замена этих двух символов на\u2028и\u2029. На iOS 14+ аргумент передаётся значением — экранирования нет вовсе; на iOS 12–13 литерал строит тот жеJSONSerialization, но два разделителя строк заменять всё равно нужно: сериализатор оставляет их сырыми на любой платформе. - На Android метод моста исполняется не на главном потоке, а обратный вызов в WebView требует главного. Ответ нужно переложить на UI-поток, иначе приложение упадёт у себя, а опрос об этом не узнает.
Что ещё лежит в этом глобале — пригодится, если вы здороваетесь раньше, чем опрос успел подняться:
| Поле | Что это |
|---|---|
protocol | версия протокола этого билда |
receive | приём команд, единственная точка входа для нативного кода |
_q | очередь нативных команд, пришедших раньше протокола, максимум 32 |
_mq | очередь ранних postMessage, максимум 8 |
ready | на него нельзя опираться: в iframe он по построению остаётся false, и решения на нём строить нечего |
Практический вывод: ранний host.hello
допустим и даже надёжнее — он попадает в очередь и исполнится, когда протокол поднимется. Опоздать хуже,
чем поспешить: в приложении окно придерживания событий под права — 5 секунд от инициализации протокола.
iOS: WKWebView
Три настройки WebView, которые стоит сделать, и одна, которую нельзя ломать:
| Настройка | Что без неё |
|---|---|
allowsInlineMediaPlayback = true |
видео в вариантах ответа уезжает в нативный полноэкранный плеер поверх опроса |
mediaTypesRequiringUserActionForPlayback = [] |
не работает автовоспроизведение видео автора |
websiteDataStore — персистентный |
это значение по умолчанию, но .nonPersistent() отнимает хранилище, а с ним прогресс и идентификацию сессии |
WKUIDelegate — обязателен |
window.open в WKWebView без него не делает ничего, а через него идут все переходы, которые протокол не делегирует: шаринг, «Посмотреть ответ», авторские ссылки «в новом окне». Молча мёртвые кнопки |
JavaScript и выбор файла на iOS работают без настройки.
На что смотреть в коде ниже:
- обработчик регистрируется в конфигурации до создания WebView и до загрузки адреса;
- приветствие отправляется в ответ на
survey.handshake, а не по завершению загрузки — так оно попадает в пятисекундное окно и покрывает все перезагрузки, которые опрос делает сам; и только на первый хендшейк, потому что второй — это уже подтверждение вашего приветствия; - тело сообщения приводится к строке: словарь здесь не приходит никогда;
- принимается только главный фрейм и только с разрешённого адреса — мост виден всем фреймам внутри WebView, включая виджет оплаты и видео;
- обработчик держится слабой ссылкой, иначе получается цикл удержания.
import UIKit
import WebKit
final class SurveyHostViewController: UIViewController, WKScriptMessageHandler, WKUIDelegate {
/// Обычный публичный адрес опроса: дописывать ничего не нужно и не надо — служебный
/// кэш-бастер стирал бы респонденту прогресс на каждой загрузке.
private let surveyURL = URL(string: "https://webask.io/ID_ОПРОСА")!
/// Origin'ы ЦЕЛИКОМ, а не хосты: опрос вправе переехать (бэкенд отдаёт другой адрес
/// того же опроса, в том числе на другом домене). Схема и порт входят в сравнение.
private let allowedOrigins: Set<String> = ["https://webask.io"]
private var webView: WKWebView!
/// Взведён — закрытие запросили МЫ: только тогда `survey.closed` адресован нам.
private var closeTimer: Timer?
override func viewDidLoad() {
super.viewDidLoad()
let config = WKWebViewConfiguration()
// Имя обработчика задано протоколом. Регистрируем ДО load(): транспорт рендерер
// определяет один раз, при старте, — мост, добавленный позже, не заметят.
config.userContentController.add(WeakScriptHandler(self), name: "webask")
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
webView = WKWebView(frame: view.bounds, configuration: config)
// Обязателен: без WKUIDelegate `window.open` не делает НИЧЕГО.
webView.uiDelegate = self
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(webView)
// Свайп-закрытие шторки выключаем намеренно: после него досохранять уже нечего —
// WebView разрушен вместе с недопечатанным вводом.
isModalInPresentation = true
webView.load(URLRequest(url: surveyURL))
}
// MARK: - Приём событий (главный поток — в отличие от Android)
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
// Мост доступен ЛЮБОМУ фрейму WKWebView — внутри опроса живут чужие (оплата, видео).
guard message.name == "webask", message.frameInfo.isMainFrame else { return }
let origin = originOf(message.frameInfo.securityOrigin)
guard allowedOrigins.contains(origin) else {
// Не молча: забытый домен = хост, слепой навсегда, а рендерер об этом
// не узнает — отправка в мост у него успешна.
print("[webask] отброшен главный фрейм с чужого origin: \(origin)")
return
}
// На проводе ВСЕГДА JSON-строка, а не словарь.
guard let body = message.body as? String,
let data = body.data(using: .utf8),
let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
envelope["__webask"] is NSNumber,
let type = envelope["type"] as? String else { return }
let payload = envelope["payload"] as? [String: Any] ?? [:]
let ctx = envelope["ctx"] as? [String: Any] ?? [:]
// Тестовый прогон отсекаем до всякой логики.
if (ctx["preview"] as? Bool) == true { print("[webask preview] \(type)"); return }
switch type {
case "survey.handshake":
// Здороваемся ровно здесь: окно придерживания событий под capability (5 с,
// отсчёт от инициализации протокола, а НЕ от didFinish) открыто именно сейчас.
// Хендшейк приходит на КАЖДЫЙ документ, поэтому все перезагрузки, которые
// рендерер делает сам, покрыты этой веткой. Но за документ он приходит ДВАЖДЫ:
// маячком и подтверждением. Здороваемся только на первый.
if (envelope["replay"] as? Bool) != true { sendHostHello() }
case "survey.ready":
// Разбор приведением типа, а не через `??`: объявленные поля приезжают явным
// `null`, то есть NSNull, и `??` на нём не срабатывает.
let title = payload["title"] as? String ?? ""
let background = payload["backgroundColor"] as? String
print("готов: \(title), фон: \(background ?? "тема без цвета")")
case "survey.completed":
// Шторку здесь НЕ закрываем: терминальный экран автора только что появился.
// Ключа `result` НЕТ ВОВСЕ без capability `results`; `score: null` внутри него
// означает «скоринг у опроса выключен». Путать эти два случая нельзя.
let reason = payload["reason"] as? String ?? "?"
var score = "не считался"
if let result = payload["result"] as? [String: Any],
let value = result["score"] as? Double { score = "\(value)" }
print("завершено: \(reason), балл: \(score)")
case "survey.navigate-request":
// Объявили `navigate` — обязаны увести респондента сами и уложиться в 1200 мс.
// СПРЯТАТЬ НЕДОСТАТОЧНО: скрытый WebView откат только откладывает.
teardown()
showOwnThanks()
case "survey.closed":
// Подтверждение на наш `host.close`. Только если закрытие запрашивали мы.
if closeTimer != nil { teardown() }
default:
break // протокол растёт аддитивно: незнакомый type игнорируем молча
}
}
/// Origin в том же виде, в каком он лежит в списке: без порта по умолчанию.
private func originOf(_ o: WKSecurityOrigin) -> String {
let isDefaultPort = o.port == 0
|| (o.`protocol` == "https" && o.port == 443)
|| (o.`protocol` == "http" && o.port == 80)
return isDefaultPort ? "\(o.`protocol`)://\(o.host)" : "\(o.`protocol`)://\(o.host):\(o.port)"
}
// MARK: - Команды хосту → рендереру
private func send(_ command: [String: Any]) {
guard let webView = webView,
let data = try? JSONSerialization.data(withJSONObject: command),
let json = String(data: data, encoding: .utf8) else { return }
// На iOS 14+ аргумент передаётся ЗНАЧЕНИЕМ, ручного экранирования нет вовсе.
// Проверка на глобал обязательна в КАЖДОМ вызове, и мир .page — тоже.
if #available(iOS 14.0, *) {
webView.callAsyncJavaScript("if (window.__webask) window.__webask.receive(msg)",
arguments: ["msg": json], in: nil, in: .page)
return
}
// iOS 12–13 (нижняя граница рендерера — 12.2): callAsyncJavaScript не существует,
// и без этой ветки ни host.hello, ни host.close не уходят вовсе — молча.
// Литерал строит ТОТ ЖЕ сериализатор, а не конкатенация: JSON оставляет U+2028 и
// U+2029 сырыми, а в старом движке это разрыв строки, то есть SyntaxError.
// Обёртка массивом, а не .fragmentsAllowed: сама опция появилась только в iOS 13.
guard let wrapped = try? JSONSerialization.data(withJSONObject: [json]),
let text = String(data: wrapped, encoding: .utf8) else { return }
let literal = String(text.dropFirst().dropLast())
.replacingOccurrences(of: "\u{2028}", with: "\\u2028")
.replacingOccurrences(of: "\u{2029}", with: "\\u2029")
webView.evaluateJavaScript("if (window.__webask) window.__webask.receive(\(literal))")
}
private func sendHostHello() {
send([
"__webask": 1, // ЧИСЛО: строку "1" рендерер молча отбросит
"type": "host.hello",
"payload": [
"min": 1, "max": 1,
"host": ["sdk": "my-app-ios", "version": "1.0.0", "platform": "ios"],
// Объявляем только то, что обрабатываем: `navigate` обязывает увести
// респондента самим.
"capabilities": ["navigate", "results"],
],
])
}
// MARK: - Закрытие: WebView разрушаем только после подтверждения
/// Зовёт кнопка закрытия шторки. `host.close` досохраняет недопечатанный ввод и
/// отложенную запись прогресса. Предохранитель 500 мс — на случай молчания.
func closeSurvey() {
guard closeTimer == nil else { return }
send(["__webask": 1, "type": "host.close", "payload": [:]])
closeTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
self?.teardown()
}
}
private func teardown() {
closeTimer?.invalidate(); closeTimer = nil
webView?.removeFromSuperview(); webView = nil
dismiss(animated: true)
}
private func showOwnThanks() { /* ваш экран «спасибо» */ }
/// `window.open` в WKWebView без этого метода возвращает null и не делает ничего.
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures) -> WKWebView? {
if let url = navigationAction.request.url { UIApplication.shared.open(url) }
return nil
}
}
/// `WKUserContentController` держит обработчик СИЛЬНО, а сам живёт в конфигурации
/// WebView: регистрация `self` замкнула бы цикл «VC → WebView → config → controller → VC».
private final class WeakScriptHandler: NSObject, WKScriptMessageHandler {
private weak var target: WKScriptMessageHandler?
init(_ target: WKScriptMessageHandler) { self.target = target }
func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) {
target?.userContentController(c, didReceive: m)
}
}
Здороваться в didFinish нельзя
didFinish ждёт все ресурсы и опаздывает к окну
capability, а срабатывает он на любой документ — включая чужие страницы, куда рендерер уводит
этот же WebView сам. Годится он только для диагностики.
Android: WebView
Мост регистрируется как addJavascriptInterface(bridge, "WebAskAndroid"),
метод помечен @JavascriptInterface и принимает
String — единственный тип, который безопасно проходит мост.
| Настройка | Что без неё |
|---|---|
javaScriptEnabled = true |
ничего: опрос — SPA, без скриптов не загрузится вовсе. По умолчанию выключено |
domStorageEnabled = true |
прогресс не переживает перезагрузку, а бэкенд видит нового респондента на каждой загрузке. По умолчанию выключено |
WebChromeClient.onShowFileChooser |
на вопрос с загрузкой файла ответить нельзя: тап по полю не открывает ничего, и респондент не получает никакого сообщения |
mediaPlaybackRequiresUserGesture = false |
не работает автовоспроизведение видео автора |
Геолокация в виджете карты требует разрешения самого приложения и обработки запроса из WebView. Ссылки «в новом окне» протокол не делегирует — их хост перехватывает сам, на уровне WebView.
Метод моста исполняется не на UI-потоке — и это роняет хост, написанный в лоб
WebView зовёт @JavascriptInterface на своём
служебном потоке JavaBridge, а любой вызов самого
WebView и любое касание вьюх разрешены только с главного. Поэтому «принял событие — тут же ответил командой»
не работает: evaluateJavascript внутри
receive даёт
RuntimeException: A WebView method was called on thread 'JavaBridge',
а обновление интерфейса — CalledFromWrongThreadException.
Падение происходит у вас, рендерер о нём не узнает и в
survey.error оно не попадёт: со стороны опроса всё
в порядке. Правило: тело метода моста только разбирает строку, всё остальное уходит на главный поток.
class WebAskBridge {
private final Handler ui = new Handler(Looper.getMainLooper());
private final WebView webView;
WebAskBridge(WebView webView) { this.webView = webView; }
@JavascriptInterface
public void receive(String json) {
// Поток JavaBridge: ни webView, ни вьюхи здесь трогать нельзя.
final JSONObject message;
try {
message = new JSONObject(json);
} catch (JSONException e) {
return; // чужая строка не должна ронять мост
}
ui.post(() -> onMessage(message)); // всё дальнейшее — на главном потоке
}
private void onMessage(JSONObject message) {
// Главный поток: здесь можно и evaluateJavascript, и UI.
}
}
Мост на Android закрывается только снаружи
Внутри @JavascriptInterface отправителя проверить
нечем: сведений о фрейме у метода нет вовсе — ни origin, ни признака главного фрейма, в отличие от
frameInfo на iOS. А позвать мост вправе любой фрейм
внутри документа: виджет оплаты, встроенное видео, HTML-виджет автора. Значит проверка переносится на уровень
навигации WebView:
- в
WebViewClient.shouldOverrideUrlLoadingпускать только адреса из своего списка origin, остальное уводить во внешний браузер; removeJavascriptInterface("WebAskAndroid")перед загрузкой чужого адреса — иначе страница, на которую опрос уехал в конце (авторская ссылка, «Спасибо»), получает прямой канал к вашему приложению;- при разрушении экрана мост снимать первым, до
destroy().
React Native: протокол сам не включится
Отдельного транспорта под React Native в версии 1 нет, и это не деталь реализации, а следствие правил активации: рендерер ищет фрейм либо мост с известным именем. WebView в RN не даёт ни одного из них ни на iOS, ни на Android — у него собственный канал, а документ в нём верхнего уровня, не фрейм. Поэтому «из коробки» протокол выключен: маячка нет, событий нет, команды игнорируются. Включить можно двумя способами.
Способ 1 — переходник над штатным каналом RN, без нативного кода.
/* injectedJavaScriptBeforeContentLoaded — именно он, см. условия ниже. */
window.WebAskAndroid = {
receive: function (json) { window.ReactNativeWebView.postMessage(json); }
};
true;
События после этого приходят в onMessage как
event.nativeEvent.data — та же JSON-строка конверта,
что и на любом другом транспорте. Обратное направление —
injectJavaScript с защищённым вызовом и
экранированием. Условий два, и оба жёсткие:
- переходник должен существовать раньше, чем стартует код документа. Обычный
injectedJavaScriptисполняется после загрузки страницы — для этого поздно; ctx.embedприедет'android'на обеих платформах. Поле называет мост, а не ОС: имя моста выбрали вы. Ветвиться по нему на iOS нельзя — платформу хост знает сам.
И одно требование безопасности, общее с нативными транспортами: переходник должен существовать только в главном фрейме. Внутри документа опроса живут чужие фреймы — виджет оплаты, встроенное видео, HTML-виджет автора, — и мост, доступный им, это их прямой канал к вашему хосту.
Способ 2 — настоящий нативный мост: зарегистрировать его на платформенном WebView так, как
описано выше, то есть написать нативный модуль. Дальше рецепты iOS и Android применяются как есть, включая правило
про поток JavaBridge, а
ctx.embed называет реальную платформу.
6. Рукопожатие и жизненный цикл
Опрос заговаривает первым. Как только он загрузился, наружу уходит
survey.handshake — сам, ничего не спрашивая.
Значит слушатель сообщений всегда что-то получает: вы сразу знаете, что протокол здесь есть и какой он версии.
Молчание в ответ на плоский фрейм — не ваш случай.
Но приходит не всё. Граница проходит по одному вопросу: зависит ли факт от того, кто именно открыл опрос.
| Что за факт | Примеры | Когда приходит |
|---|---|---|
| Про сам опрос и про загрузку | рукопожатие, опрос готов, ошибка загрузки, опрос закрыт автором | Сразу, без приветствия |
| Про этого человека | начал проходить, перешёл к экрану, ответил, отправил, завершился, нужна авторизация, оплата | После вашего host.hello |
Причина не в вежливости. Пока вы не поздоровались, опрос не знает, кому отправляет. Факты про сам опрос от этого не страдают: их узнаёт кто угодно, просто открыв публичную ссылку. А факты про конкретного человека адресату без имени отдавать нельзя. Это не рассылка: получателем может быть только фрейм-родитель — тот, кто опрос и встроил. Страница, обернувшая вашу страницу, не получит ничего.
Порядок
| Кто | Что |
|---|---|
| Опрос | survey.handshake — сам, как только загрузился: версии, которые понимает, и номер сборки |
| Опрос | факты про опрос — готов, ошибка, закрыт: по мере того как случаются |
| Хост | host.hello — представляется и перечисляет права, которые ему нужны |
| Опрос | survey.handshake ещё раз, с replay: true — приветствие принято, права зафиксированы |
| Опрос | накопленное — события про человека, случившиеся до приветствия, приходят пачкой |
| Опрос | дальше по ходу — всё остальное по мере прохождения |
Накопленное — важная часть. Человек успевает начать проходить до того, как вы поздоровались, и эти события не
теряются: они лежат в буфере (до 32, старые вытесняются) и уходят сразу после приветствия. Исход
загрузки при этом переигрывается с пометкой replay —
даже если он уже приходил вам раньше.
Одно и то же событие приходит дважды — штатно
Рукопожатие — всегда: маячком и подтверждением. Исход загрузки — если он случился до вашего
приветствия. Всё неидемпотентное — счётчики, начисления — вешайте только на события
без replay.
Но повтор нельзя просто отбрасывать: он несёт больше, чем первый экземпляр. До приветствия
адресат не подтверждён, поэтому поля про конкретного посетителя приезжают обнулёнными —
ready.resumed в первом
survey.ready
в iframe всегда null, а настоящее
значение приходит только с повтором. На нативном мосту этого не бывает: соединение доверено с первой
миллисекунды, и первый же экземпляр полный — ждать там повтора за настоящим
resumed нельзя, его может не быть вовсе.
Хост, который оставил первый экземпляр и выбросил повтор, навсегда потерял факт «человек вернулся к
незавершённому прохождению».
Отсюда правило: дедуплицируйте исход загрузки по паре
«ctx.instanceId плюс тип события», а не
по seq — у повтора он свой, новый. И не заменяйте, а
сливайте: поля про посетителя берите из того экземпляра, где они не
null.
seq остаётся тем, чем и был — детектором потерь
(дырка в нумерации) в пределах одного документа.
Здороваться нужно на каждую загрузку документа
Хост, поздоровавшийся один раз, после первой же перезагрузки навсегда перестаёт получать события.
Перезагрузился документ — исчезли и экземпляр (ctx.instanceId),
и счётчик (seq снова с 1), и зафиксированный origin,
и объявленные права, и буфер. Второе приветствие в пределах одного документа опрос игнорирует целиком.
А перезагружает себя опрос сам, не спрашивая хоста, минимум в восьми продуктовых сценариях:
- принят верный пароль у опроса под паролем;
- подтверждён email (гейт верификации);
- вернулись из попапа SSO;
- хост прислал
host.reset; - респондент нажал «пройти ещё раз» на финальном экране;
- автор опубликовал новую версию, респондент принял её из снекбара;
- выкачен новый билд рендерера — применяется молча, когда документ скрыт;
- плюс переезд опроса на другой адрес, «Обновить» на экране ошибки и уход на правку ответа.
Триггер один и он общий для всех трёх транспортов: первый
survey.handshake этого документа — тот, у
которого нет replay. Вместе с ним приезжает всё,
чего для приветствия не хватало: origin документа (в вебе) и
ctx.instanceId, по которому видно, что документ новый.
Что при этом хост обязан сделать у себя:
- сбросить состояние экземпляра: последний
seq, признак «приветствие отправлено», зафиксированный origin, накопленный прогресс, дедупликацию исходов; - веб-хосту — заново определить origin документа из заголовка пришедшего маячка
(
event.origin), никогда из тела сообщения: опрос мог переехать, и старый origin отбросит доставку команд; - не считать молчание после приветствия ошибкой: документ мог уехать на роут без протокола;
- не разрушать фрейм или WebView в ожидании ответа — ответа может не быть вовсе.
В приложении на приветствие есть около 5 секунд
На нативном мосту соединение доверено по построению, поэтому события про человека идут и без
приветствия — кроме тех, что зависят от прав. Эти опрос придерживает в ожидании и по истечении срока
выбрасывает. Срок — 5 секунд, и отсчёт идёт от инициализации протокола, а не от готовности страницы.
Поэтому здороваться надо в ответ на маячок, а не в
didFinish /
onPageFinished: на медленной сети между этими
моментами укладываются все пять секунд. Первой пропадает цель
start — она срабатывает при загрузке опроса.
Одно исключение из придерживания, и оно дорогое: право
results в очередь не попадает — оно подавляет
не событие, а поле внутри него. Значит
survey.completed, отправленный до принятого
приветствия, приезжает без ключа
result — и второй раз не приедет: переигрывается
только исход загрузки, а завершение исходом не является. Опоздали с приветствием — балла не будет никогда.
Ещё одна причина здороваться на первый хендшейк, а не на готовность страницы.
Чем полезен маячок
Он решает две задачи, которые раньше решались вручную и плохо.
- Проверка живости. Пришёл маячок — протокол на месте. Не пришёл — дело не в приветствии: либо адрес не тот, либо это роут без протокола, либо мост в приложении не зарегистрирован.
- Адрес опроса, который вы не угадывали. Опрос вправе уехать сам — на другой домен инсталляции
или с
wwwна не-www. Раньше это молча убивало протокол: приветствие уходило на прежний адрес. Теперь новый документ присылает маячок со своего адреса, и вы здороваетесь с тем, который увидели.
Опоздали со слушателем? Это штатный случай — скрипт из менеджера тегов, инициализация после
cookie-баннера, фрейм, уже отрендеренный в HTML. Маячка вы не увидите, и второго не будет: второй хендшейк уходит
только на принятое приветствие. Тупика нет — host.hello
принимается в любой момент, а адресатом берите
event.origin первого дошедшего
сообщения любого типа, прошедшего ваши проверки. Цену платит один пункт: без маячка «сообщение пришло от документа
опроса» подтверждать нечем, кроме
списка адресов инсталляции — такому хосту он
обязателен.
Дешёвый признак того, что вы опоздали: первое увиденное сообщение пришло с
seq > 1 при неизменном
ctx.instanceId.
7. Права (capabilities)
Права перечисляются в приветствии, и их четыре. Запрашивайте только то, что обрабатываете: незаявленное просто не придёт. Оговорка важная: «ничего не просил» означает «не получит фактов про посетителя» — маячок и факты про сам опрос уходят прямому родителю и без приветствия (см. выше про совместимость).
| Право | Что открывает | Когда нужно |
|---|---|---|
navigate |
Событие о терминальном переходе, и опрос перестаёт уходить сам | Вы показываете свой экран концовки |
results |
Балл и категорию в событии о завершении | Реакция на итог: извинение недовольному, предложение довольному |
answers |
Событие о том, что вопрос получил ответ. Значения ответа в нём нет | Свой индикатор заполнения |
analytics |
Цели аналитики, настроенные автором опроса | Переслать цели в свою систему аналитики |
Право navigate меняет поведение опроса для человека
Заявив его, вы берёте концовку на себя: опрос ждёт вас 1200 мс и только потом уходит сам.
Если вы ничего не сделаете, человек эту паузу увидит. Либо обрабатывайте
survey.navigate-request, либо не запрашивайте это
право.
Зачем права, если хост объявляет их себе сам?
Это не граница безопасности и не защита от враждебного хоста: авторизации хоста в системе не существует, любая страница может встроить опрос и попросить что угодно. Права решают две другие задачи — совместимость (существующее встраивание не начинает получать факты про посетителя, хотя маячок и факты про сам опрос теперь видит) и сокращение поверхности по умолчанию (данные не уходят туда, где их некому обработать). Расширить список потом нельзя: второе приветствие в пределах документа игнорируется целиком.
8. Конверт сообщения
Одна форма для всех событий, на всех трёх транспортах:
{
"__webask": 1, // маркер и версия протокола
"type": "survey.completed",
"seq": 7, // счётчик с 1, дырка = потерянное сообщение
"ts": 1786824064103,
"replay": true, // только у переигранных, иначе поля нет
"ctx": {
"instanceId": "m3f8x-a91", // уникален на загрузку документа
"virtualId": "ID_ОПРОСА",
"versionId": "12",
"embed": "iframe", // iframe | ios | android
"preview": false // true = тестовый прогон, за событиями нет записи
},
"payload": { "reason": "finish", "widgetId": "w_18" }
}
На проводе это строка JSON, а не объект
И в postMessage на сайте, и в мосту приложения.
Разбирайте её первым делом и в try/catch
— на странице бывают сообщения от других библиотек, и они не должны ронять ваш обработчик. Обратное
направление асимметрично: команды хоста в вебе отправляются объектом.
__webaskобязан быть числом. Строку"1"рендерер молча отбросит — это касается и ваших команд.- Смена
ctx.instanceIdозначает новую загрузку документа: счётчик начинается заново, и состояние на вашей стороне надо сбросить. seqнужен только для детекта потерь в пределах одногоinstanceId. Порядок событий по нему строить нельзя — см. врезку ниже.- Незнакомые
typeи незнакомые поля хост обязан игнорировать молча: протокол растёт аддитивно, и новые события совместимость не ломают. - Два опроса на одной странице — штатный случай, но раскладывать события по
ctxнельзя: его проставляет сам опрос, иvirtualIdу двух врезок одного и того же опроса совпадёт. Атрибуция — строго по окну-отправителю (event.source), то есть держите состояние рядом со своим объектом фрейма. На Android то же самое острее: мост там один на все фреймы WebView.
Как согласуется версия — и что будет, если не сойдётся
В приветствии вы объявляете диапазон min —
max, опрос в маячке тоже. Рабочей становится
меньшая из двух верхних границ. Поэтому хост, умеющий только версию 1, обязан объявлять
min: 1, max: 1 — это не формальность.
Если диапазоны не пересеклись вовсе, приветствие канал не открывает, а сужает: наружу уходит
только survey.error с
code: 'protocol-unsupported', второго хендшейка не
будет вовсе — подтверждать нечего, — а survey.closed и
survey.pong перестают приходить, хотя сами команды
исполняются. Хост, который ждёт подтверждения приветствия, здесь зависнет.
Порядок событий гарантирован только один
survey.handshake — первое. Остальное хост обязан
обрабатывать независимо от порядка. В частности,
survey.started может прийти раньше
survey.ready: при возврате к незавершённому
прохождению сессия уже существует, а ready ждёт,
пока данные опроса не разобраны.
9. События опроса — все 15
Колонка «до приветствия» отвечает сразу на два вопроса, потому что граница у них одна и та же. Первый — что придёт, пока вы не поздоровались. Второй — что доступно во встраивании, где браузер не даёт опросу опознать вашу страницу (sandbox-фрейм конструктора сайтов): там события про человека не приходят вообще никогда, даже после приветствия.
| Событие | Когда приходит | Поля | Право | До приветствия |
|---|---|---|---|---|
survey.handshake |
Маячок при загрузке и второй раз — ответом на ваше приветствие | min, max, buildId, appVersion |
— | да |
survey.ready |
Опрос отрисуется и готов к прохождению | title, locale, screenCount, hasFinishScreen, backgroundColor, resumed |
— | да |
survey.started |
Человек начал проходить | startedAt |
— | нет |
survey.page-changed |
Показан другой экран | index, total, progress, direction, widgetId, widgetType |
— | нет |
survey.question-answered |
Вопрос впервые получил ответ. Значения ответа нет | answeredCount, widgetId, widgetType |
answers |
нет |
survey.submitted |
Ответы ушли на сервер и приняты | isScreenout |
— | нет |
survey.completed |
Показывается терминальный экран. Не значит, что человек его досмотрел | reason, widgetId, result?: { score, category } |
results для result |
нет |
survey.navigate-request |
Опрос просит увести человека со страницы | reason: thanks | finish-link |
navigate |
нет |
survey.blocked |
Опрос недоступен этому человеку или закрыт вообще | reason, retryable |
— | только про опрос |
survey.auth-required |
Нужна авторизация до показа вопросов | method: password | sso | email |
— | нет |
survey.payment |
Изменилось состояние оплаты внутри опроса | status: started | succeeded | canceled | interrupted |
— | нет |
survey.analytics |
Сработала цель, настроенная автором опроса | goal, type, elementId |
analytics |
нет |
survey.closed |
Ответ на команду закрытия: ввод сохранён | hadUnsavedProgress |
— | да |
survey.error |
Опрос не смог загрузиться или отправить ответ | code, status, exception, fatal |
— | да |
survey.pong |
Ответ на проверку связи | nonce |
— | да |
Три ошибки, которые делают почти все
1. Не закрывайте опрос по survey.completed
Событие уходит в тот момент, когда опрос переключается на терминальный экран, — не когда человек с ним закончил. Закрыв шторку здесь, вы отнимете у него финальный экран автора: текст, промокод, кнопку шаринга, ссылку на свой ответ. При отсеве опрос остаётся на экране всегда.
2. Терминальную ветку различают по паре полей
Уходит со страницы только reason: 'finish'
с пустым widgetId — своего финального экрана у
опроса нет, и об этом приходит отдельный
survey.navigate-request. Во всех остальных случаях
закрытие — действие человека, а не ваше. Отлаживать эту ветку нужно на опросе
без своего финального экрана: у опроса с финальным экраном её нет вовсе.
Но обработчик перехода вешайте безусловно, а не внутри разбора
completed. Обратное не работает:
survey.navigate-request из полей
survey.completed не выводится и приходит там, где
completed не отправляется вовсе — например
респонденту, который этот опрос уже проходил
(blocked: already-answered) и которого опрос уводит
со страницы.
3. result отсутствует и result.score === null — это разные вещи
Ключа result нет вовсе — значит
хост не просил право results.
result.score равен
null — значит скоринг у опроса выключен,
считать нечего. Первое — факт про вашу же интеграцию, второе — факт про опрос. Отлаживать надо на опросе,
где скоринг включён, иначе одно неотличимо от другого.
Что важно знать про отдельные поля
| Поле | Чего по нему нельзя предполагать |
|---|---|
ready.screenCountpage-changed.total |
Это снимок, а не итог. Опрос дописывает в порядок обхода виртуальные экраны, которых в структуре нет (экран отсева по умолчанию, финальный экран, собранный из настроек кнопки отправки), поэтому total может расти по ходу. Кэшировать screenCount как знаменатель нельзя — полосу прогресса пересчитывайте по total каждого события. |
page-changed.direction |
Три значения: 'forward' и 'back' — одиночный шаг, 'jump' — прыжок условной логики. То есть jump прямо сообщает, что у опроса сработало ветвление. |
analytics.goal |
Готовый идентификатор цели, тот же, что уходит в веб-пиксели автора: пересылайте как есть, урезать нельзя — получится цель, которой в его аналитике не существует. type — start, submit, btn_finish и подобные; идентификаторы счётчиков автора не передаются. |
page-changed.progress |
Ветвление делает путь короче объявленного: пропущенные логикой экраны из порядка обхода не исчезают, поэтому прогресс вправе прыгать и не дойти до 1 линейно. |
ready.hasFinishScreen |
Описывает структуру на момент готовности и терминальную ветку не предсказывает: экран может быть собран позже из настроек кнопки отправки, а отсев приводит к своему экрану независимо от этого поля. Планировать концовку по нему нельзя — только по survey.completed. |
ready.resumedclosed.hadUnsavedProgress |
null означает «не сообщается», а не «нет». Это единственные поля про конкретного посетителя внутри событий, которые уходят и без приветствия, поэтому неподтверждённому адресату они не отдаются. В обычном веб-порядке первый ready всегда несёт здесь null, а настоящее значение приезжает с повтором (replay: true). |
ready.backgroundColor |
Красить надо контейнер, а не фрейм: документ опроса закрашивает свой прямоугольник целиком, и полоса чужого цвета видна на стыке — в модалке это шторка, в приложении статус-бар. null — тема без цвета. |
ready.locale |
Хост его читает, а не задаёт: языком встроенного опроса он не управляет — ни командой, ни параметром адреса. |
completed.widgetId |
Может оказаться идентификатором виртуального экрана, которого в структуре опроса нет. Сопоставлять его с публичным списком не нужно — значимо только «какой экран показан». |
completed.result.category |
Идентификатор из настроек автора, а не название. null — категорийный режим выключен либо ни одна категория не набрала баллов. |
error.code |
Пять значений: network (сеть или не догрузившийся код опроса), server, render (краш рендерера), protocol-unsupported (версии не сошлись), unknown. status — HTTP-статус ответа, null у ошибок не от HTTP. |
error.exception |
Только имя класса (TypeError, ChunkLoadError), но не его текст. Свободного текста протокол не носит вовсе — ни ответа бэкенда, ни текста исключения. |
error.fatal |
Не отвечает на вопрос «опрос ещё отвечает на команды», и неверно в обе стороны. Признак мёртвого опроса один и он наблюдаемый: host.close ушёл, а survey.closed не пришло за 500 мс. При этом host.ping продолжает отвечать: протокол живёт отдельно от экрана прохождения. |
Причины блокировки
Делятся на две группы, и это важно: одни говорят о самом опросе, другие о конкретном посетителе. Вторые не приходят в sandbox-фрейме конструктора и не приходят до приветствия.
| Группа | Значения |
|---|---|
| Про опрос — одинаково для любого, кто откроет ссылку | quiz-closed, schedule-closed, archived, invalid-uuid, answer-limit, time-limit |
| Про посетителя — другому откроется | already-answered, one-ip, extra-params, device-blocked, ip-denied, password-attempts |
retryable отвечает на вопрос «есть ли смысл
повторить позже»: у закрытого по расписанию — да, у неверного адреса — нет.
Незнакомую причину обрабатывайте как общий отказ: список может пополниться без смены версии
протокола. Две причины из списка —
one-ip и
extra-params — в версии 1 на провод не выходят:
в этой ветке исход загрузки survey.ready, а о том,
что респондент уже проходил опрос, хост узнаёт из
already-answered.
Оплата: единственное событие, из-за которого нельзя закрывать шторку
Между started и терминальным статусом у респондента
может быть открыто 3DS-подтверждение банка — то есть деньги в движении. Хост, закрывший в этот момент шторку,
обрывает платёж, и узнать об этом иначе нельзя: виджет оплаты живёт в своём фрейме. Отдельного права у события нет
намеренно — не знать про идущий платёж опаснее, чем получить лишнее событие. Данных карты здесь нет и быть не может.
Но ждать терминальный статус бессрочно нельзя.
succeeded,
canceled и
interrupted рождаются из событий платёжного
виджета, поэтому гарантии, что после
started придёт хоть один из них, у протокола нет:
респондент мог уйти со страницы, WebView мог быть уничтожен, виджет мог не сообщить ничего.
Рекомендуемый предел ожидания — порядка 5 минут от
started: подтверждение банка приходит SMS и минуты
занимает штатно, а держать шторку дольше уже хуже, чем закрыть. Вышел срок — закрывайте; прошёл платёж или нет,
решается не по событию, а по серверному факту.
Что опрос НЕ делегирует хосту
Делегируется только терминальный переход внутри своего фрейма. Всё остальное опрос выполняет сам,
и события для этого нет: новое окно, шаринг, «Посмотреть ответ», «Редактировать ответ», ссылки автора, попап SSO.
Практическое следствие для приложения: эти переходы идут через
window.open, а он в WebView без делегата не делает
ничего — кнопки будут молча мёртвыми, пока вы не перехватите их на уровне WebView.
И отдельно про высоту: события про высоту контента в протоколе нет. Пока опрос занимает весь вьюпорт, потребителя у такого события не существует.
10. Команды опросу — все 4
Белый список входящих — ровно четыре типа. Всё остальное, что придёт в документ опроса сообщением, протокол молча отбрасывает, ничего не отвечая.
| Команда | Поля | Что делает | Ответ |
|---|---|---|---|
host.hello |
min, max, host: { sdk, version, platform }, capabilities |
Устанавливает связь и фиксирует права. Принимается один раз за загрузку | survey.handshake с replay |
host.close |
— | Просит сохранить недопечатанный ввод перед закрытием. На бэкенд при этом ничего не уходит | survey.closed |
host.reset |
— | Стирает прогресс текущего опроса и перезагружает его | перезагрузка, события нет |
host.ping |
nonce? |
Проверка, что опрос ещё отвечает | survey.pong с эхом nonce |
Не убирайте фрейм или WebView сразу после команды закрытия
Ради подтверждения host.close и существует: оно
означает «ввод досохранён». Не дождавшись, вы потеряете то, что человек набрал в последнем поле и что дебаунс
не успел записать. Ждать долго не нужно — опрос отвечает в том же тике, поэтому
предохранитель 500 мс: не пришло за это время — не придёт, разрушать безопасно.
host.reset — деструктивная команда
Она стирает ответы человека по текущему опросу. Не используйте её, чтобы «вернуть опрос в
рабочее состояние». Ответного события нет — вместо него идёт обычный старт нового экземпляра: новый
ctx.instanceId,
seq с единицы, и
здороваться нужно заново. Чистится только текущий опрос: глобальный сброс снёс бы прогресс
остальных опросов респондента.
И главное для отладки: это чистый лист для опроса, а не новый респондент. Серверная запись
ответа перезагрузку переживает, поэтому после сброса остаются
already-answered,
answer-limit,
time-limit и блокировки по IP и устройству;
идентификация сессии и cookie командой тоже не чистятся. Прилетевший
survey.blocked здесь — не поломка вашей интеграции.
Чтобы прогонять опрос повторно, автор временно разрешает повторные ответы
(allow_multiple_answer), либо проверяйте с нового
профиля браузера; способ сверки задаёт настройка автора
session_check_method —
ip,
extra_params или
cookie. Частая ложная поломка: при сверке по IP опрос
сначала отрисуется (survey.ready), а
already-answered придёт отдельным событием после.
Когда команда не срабатывает
«Всегда» тут не бывает, и все четыре случая наблюдаемы как молчание:
- Роут. Команды принимаются там, где протокол вообще включён. На остальных роутах нет даже маячка — поэтому «команда ушла в никуда» отличимо от «команда не сработала»: молчание начинается с самого первого сообщения, которого нет.
- Приветствие. В iframe
host.close,host.resetиhost.pingпринимаются только после принятого приветствия и только с его origin. На нативном мосту — сразу, приветствие не требуется. - Версии не сошлись. Случай отдельный, потому что молчание здесь наступает: такой хост
подключён ровно настолько, чтобы получить
survey.errorсprotocol-unsupported, а его команды исполняются — origin приветствия зафиксирован. То естьhost.closeввод досохранит, аhost.resetдокумент перезагрузит, только подтверждений не будет:survey.closedиsurvey.pongиз этого состояния уже не уходят — вместе с ними ломается и рекомендованная проверка живости. - Подписчик.
host.closeиhost.resetисполняет код экрана прохождения, аhost.helloиhost.ping— сам протокол. Отсюда разное поведение: пинг отвечает всегда, пока протокол жив, а закрытие и сброс зависят от того, смонтирован ли опрос.
Когда подписчика нет, команда не теряется: она кладётся в буфер (последняя каждого типа выигрывает) и исполняется,
когда подписчик появится. Если опрос размонтирован из-за сбоя, подписчиков больше не будет —
survey.closed не придёт никогда, а
host.ping продолжает отвечать.
11. Что хост узнаёт, а что не узнаёт никогда
Встроенный опрос стоит на чужой странице, поэтому вопрос «что именно сайт узнаёт о человеке» не теоретический. Граница проведена жёстко и одной фразой: наружу уходит ход прохождения и никогда — содержание ответов. Протокол носит информацию и никогда — доступ.
| Что | Уходит хосту |
|---|---|
| Опрос открылся, человек начал | да |
| Перешёл к следующему вопросу, номер шага | да |
| Ответил на вопрос — сам факт | да |
| Отправил ответы, завершил или был отсеян | да |
| Нужна авторизация, опрос закрыт, ошибка | да |
| Итог: балл и категория результата | да, если хост запросил право results |
| Что именно человек выбрал и написал | нет |
| Промокод с финального экрана | нет |
| Ссылка на редактирование или просмотр ответа | нет |
| Идентификатор ответа, хеш прохождения, время прохождения с бэкенда | нет |
Почему граница именно здесь: всё, что могло бы служить идентификатором записи ответа, является ключом доступа к ней. Единственный такой идентификатор на клиенте — хеш сессии, и им подписаны запросы, которые дописывают ответ, загружают файлы респондента и создают платёж. Кто его получил, тот может дописывать и подменять ответ. Промокод не отдаётся по отдельной причине — он не выводится ниоткуда и имеет денежную стоимость, а согласия автора опроса на его передачу встроившему хосту в системе не существует.
Честно про догадки — таблица этого не покрывает
Сами ответы не передаются никогда. Но если опрос ветвится — разные ответы ведут на разные экраны, — то по тому, какой экран показан следующим, можно понять, какой вариант выбран в таком вопросе: структура опроса публична, её видит любой, кто открыл ссылку. Свободный текст, числа и ответы на вопросы, которые маршрут не меняют, вывести нельзя. В линейном опросе без ветвлений нельзя вывести ничего.
И один крайний случай: если весь балл опроса держится на одном вопросе, то балл — это и есть ответ на него. Для NPS из одного вопроса так и получается, и в этом нет проблемы: балл затем и передаётся. Просто не обещайте в таком случае, что ответ останется неизвестным.
Что протокол не гарантирует: подлинность событий
События приходят с клиента, значит подделать их может тот, у кого есть браузер. Начисление бонусов,
выдачу промокодов и отметку «опрос пройден» стройте на серверной стороне — событие
survey.completed годится, чтобы показать нужный
экран, и не годится как доказательство. То же и про права: раз хост объявляет их себе сам, балл, отданный
под results, отдан фактически любому хосту,
который его попросит.
Короче говоря: владелец сайта видит, как идёт прохождение и с каким итогом оно закончилось, но не видит сами ответы. Результаты забираются в кабинете, за входом по паролю, а не через страницу, на которой стоит опрос.
12. Что обязан проверять хост
Маркер конверта подлинности не доказывает
На странице может быть несколько фреймов и чужие скрипты. Сообщение, в котором стоит наш маркер, могло прийти
от любого из них. __webask — фильтр «наш трафик», а
не подпись: внутри опроса исполняется JS автора опроса, и подделать конверт он может. Поэтому источник и origin
проверяются раньше, а не вместо.
Порядок проверок на входящем — именно в этом порядке:
- Источник — тот самый фрейм. Сравните отправителя с окном вашего фрейма
(
event.source !== frame.contentWindow), а не просто примите сообщение. ПустойcontentWindowпроверяйте отдельно:null !== nullэтоfalse, и после снятия фрейма условие пропускало бы синтетическое сообщение. - Origin — из
event.origin, не из тела. Тело сообщения подделывается, заголовок — нет. Если список у вас есть, сверяйте origin целиком, со схемой, а не подстрокой. Значение'null'на приёме законно (вы сами поставилиsandboxбезallow-same-origin) — тогда оно заменяет собой список, а не дописывается в него, а отправлять такому документу можно только на'*'(почему — в разделе 4). И гейтить это надо признаком со своего фрейма, а не фактом прихода'null': иначе вы начнёте безусловно принимать непрозрачный origin, а он приходит и от чужих документов. - Только потом разбирайте JSON, и только в
try/catch. - И только потом смотрите тип и поля.
Нужен ли список адресов опроса
Веб-хосту по умолчанию — нет. Origin документа приезжает с маячком, и адресат команд берётся оттуда же: угадывать заранее нечего. Раньше список был обязателен именно потому, что угадывать приходилось, — и неполный список убивал встраивание молча, стоило опросу переехать. Как фильтр «наш ли это домен» на приёме он остаётся полезным.
Но трём хостам он обязателен, и причина у первых двух одна:
event.source доказывает «это наш фрейм»
и не различает документы внутри него — contentWindow
остаётся тем же объектом, куда бы фрейм ни уехал. А уезжает он в конце на адрес, который задал автор опроса, и та
страница вправе прислать поддельный хендшейк.
| Кому обязателен | Почему |
|---|---|
| Хост, встраивающий опросы чужих авторов | Адрес, на который фрейм уезжает в конце, задаёт автор опроса. Приветствие отправляйте только на origin из списка, а маячок с origin вне списка игнорируйте |
| Хост, чей слушатель может быть повешен позже инициализации протокола | Маячка он не увидит и будет здороваться на первое дошедшее сообщение — а «от опроса ли оно», кроме списка, подтвердить нечем |
| Нативный хост — всегда | Там нет ни event.source, ни адресата отправки: список — единственное, чем ограничивается доступ к мосту. iOS сверяет с ним frameInfo.securityOrigin, Android им же закрывает мост от чужих адресов |
Как его собрать — три источника:
- origin встраиваемой ссылки — схема, хост и порт, без пути. Сравнение точное:
https://webask.ioиhttps://www.webask.io— разные origin,httpиhttps— тоже; - кастомные домены инсталляции — все, с которых опросы этого аккаунта могут отдаваться;
- адрес, на который опрос может переехать: в ответ на загрузку бэкенд вправе отдать другой адрес того же опроса, в том числе на другом домене.
Чего в список не входит: адрес страницы самого хоста (список — про адреса опроса), адреса из
содержимого сообщений, маски и регулярные выражения. Строка
'null' — тоже не адрес.
Диагностика неполного списка — одна строка лога
Пропущенный домен не выглядит как полное молчание: маячок приходит, а сверка origin отбрасывает его молча.
Логируйте event.origin сообщений, которые прошли
проверку источника и маркера, но не прошли проверку origin. Это лог, а не приём: список от
него не расширяется, зато пропавший домен становится виден одной строкой вместо расследования. Нативному
хосту тот же лог обязателен — там сверка с origin единственный барьер.
13. Тестовый режим
Отлаживайтесь на адресе предпросмотра — /<ID_ОПРОСА>/preview.
Это штатный тестовый режим: тот же протокол, тот же порядок сообщений и те же поля, что на настоящем прохождении,
но отправка имитируется у клиента.
| В тестовом прогоне | Что это значит |
|---|---|
| На сервер не уходит ничего | Лимит ответов автора не тратится, в выгрузке не появляется строки |
| Дедуп «уже отвечал» не считается | Прогонять можно сколько угодно раз подряд |
| Счётчики автора не срабатывают | Ваши заходы не попадают в его Метрику, GA и Facebook. При этом survey.analytics в протокол уходит — пересылку целей в свою аналитику можно отладить |
Наружу отличие ровно одно: в каждом сообщении
ctx.preview равно
true. Одно оно намеренно — если бы в
тестовом режиме менялся состав событий, отладка ничего не доказывала бы.
Отсюда обязанность, которую нельзя пропустить
Отсекайте тестовый поток по этой пометке в самом начале обработчика. Иначе ваш отладочный прогон начислит бонус, отметит конверсию и закроет шторку так же, как настоящее прохождение: события оттуда по форме неотличимы от настоящих — они и обязаны быть неотличимы.
/* первой строкой обработчика, до всякой логики */
if (msg.ctx.preview) { logForDebug(msg); return; }
Пометка живёт в конверте, а не в payload'ах, поэтому забыть её при добавлении нового события невозможно.
Предпросмотр одного виджета (/<ID>/preview/question/<ID_ЭКРАНА>)
— не тестовый режим: там протокол выключен целиком. Порядка экранов и отправки там нет, то есть событие было бы
вымыслом даже по форме, и помечать его нечем. И ещё две вещи про отладку: в предпросмотре
серверного эха событий нет — наблюдать поведение можно ровно в одном месте, в собственном логе
провода; и рендерер о вашей стороне не сообщает ничего — падение хоста в
survey.error не попадает.
14. Отладка: порядок и молчаливые поломки
Порядок выбран так, что каждый шаг проверяет ровно одно звено и не требует предыдущих догадок.
- Логируйте провод целиком. Весь конверт плюс отдельно
type,seq,ctx.instanceIdиreplay. Без этого лога отлаживать нечем: остальные шаги — это чтение именно его. - Дождитесь маячка и поздоровайтесь в ответ. В вебе слушатель
messageвешается в том же синхронном блоке, что и создание фрейма; в приложении — подписка на мост до загрузки адреса. Убедитесь, что приветствие ушло на тот origin, который в маячке, и что на второй хендшейк (replay: true) вы не здороваетесь повторно: он и есть подтверждение. - Проверьте обратный канал:
host.ping→survey.pongс тем жеnonce. Это единственная команда версии 1 с ответом, то есть единственный способ доказать, что ваши сообщения доходят. Делайте это до того, как подключать остальное: у закрытия и сброса подтверждения либо нет, либо оно зависит от состояния опроса. На Android этот же шаг проверяет, чтоevaluateJavascriptзовётся с главного потока. - Дождитесь исхода загрузки —
ready,auth-required,blockedилиerror. Проверьте, что обработчик идемпотентен: то же событие может приехать второй раз сreplay: true. - Пройдите один экран — ждём
survey.page-changed. Пришёлready, а этого нет — причин две, и различает их наблюдаемый признак на вашей стороне: еслиlocation.originвашей собственной страницы равен'null'— в песочнице конструктора сидите вы сами, и события про человека не придут никогда. Обычныйlocation.originзначит другое: приветствие не доехало, проверяйте шагом 3. - Включите права и повторите.
question-answeredиanalyticsне приходят, пока не объявлены; на нативном мосту объявленные слишком поздно теряются — окно 5 секунд от инициализации протокола. - Отправьте ответы —
submitted, затемcompleted. Разбирайте пару полейreason+widgetId, а не одно. Объявилиresults— проверьте здесь же, что внутри приехал объектresult, и делайте это на опросе со включённым скорингом. - Проверьте терминальную ветку на отдельном опросе БЕЗ своего финального экрана. Только там
уходит
navigate-request. Заодно убедитесь, что снимаете опрос за 1200 мс и что именно снимаете, а не прячете, — скрытый документ откат только откладывает. - Проверьте закрытие в два шага:
host.close→survey.closed→ и только теперь разрушение контейнера. Отдельно проверьте предохранитель на 500 мс: штатное молчание дешевле всего воспроизвести адресом, где протокола нет вовсе — наведите хост на/<ID>/preview/question/<ID_ЭКРАНА>. - Проверьте перезагрузку. Пришлите
host.reset(или возьмите опрос под паролем) и убедитесь, что здороваетесь заново, видите новыйctx.instanceIdиseqс единицы, а своё состояние сбрасываете. Шаг обязателен: хост, поздоровавшийся один раз, после первой же перезагрузки навсегда перестаёт получать события.
Чем проверять живость НЕ надо
- Флагом
window.__webask.ready— он остаётсяfalseв штатных случаях. - Полем
fatal— признак мёртвого опроса другой:host.closeбезsurvey.closedза 500 мс. - Приходом
survey.ready— он больше не доказывает, что ваше приветствие принято: факты про сам опрос уходят и без него. Доказываетsurvey.pongи приход событий про человека. - Отсутствием события — молчание штатный ответ, а не ошибка.
Что ломается молча — и что с этим делать
| Симптом | Почему | Что делать |
|---|---|---|
| На сайте сообщений нет вообще | Приветствие тут ни при чём: маячок приходит и без него. Значит либо адрес не тот, либо роут без протокола, либо источник и origin отсеяли сообщение вашей же проверкой | Проверить, что слушатель вешается до создания фрейма, и залогировать отброшенные сообщения |
| В приложении сообщений нет вообще | Мост не зарегистрирован или зарегистрирован после начала загрузки — приветствие тут ни при чём | Поставить обработчик в конфигурацию WebView до загрузки адреса |
| Маячка нет, а другие события идут | Вы опоздали со слушателем. Дешёвый признак: первое увиденное сообщение с seq > 1 |
Здороваться на первое дошедшее сообщение любого типа; список адресов инсталляции такому хосту обязателен |
| Приходит только загрузка | Встраивание в песочнице: браузер не даёт опросу опознать вашу страницу | Проверить location.origin у себя; на обычной странице приходит всё |
| Приходит только про сам опрос, про человека — нет | Не поздоровались, или поздоровались один раз, а опрос успел перезагрузиться | Здороваться на каждую загрузку документа, отслеживая ctx.instanceId |
| Часть событий не приходит | Не запросили соответствующее право или запросили после рукопожатия | Объявлять права в первом же host.hello — расширить список потом нельзя |
| Человек видит паузу перед уходом | Заявлено право navigate, но обработчик ничего не делает |
Либо обработать, либо не запрашивать это право |
| Последний ответ пропал | Фрейм убрали, не дождавшись ответа на команду закрытия | Ждать survey.closed или таймаут 500 мс |
| Оплата обрывается | Окно закрыли, пока человек подтверждал платёж в банке | Не закрывать между started и терминальным статусом, но не дольше ~5 минут |
| Прогресс сбрасывается каждую загрузку | К адресу добавлен меняющийся параметр — опрос считает его признаком другого человека | Убрать из адреса всё, кроме осмысленных скрытых переменных |
| Через сутки опрос начинается сначала | Срок жизни идентификации — 24 часа, абсолютных: отсчёт от первой выдачи, обращениями не продлевается | Учесть в сценарии, если возврат ожидается позже |
| Хост считает отправку дважды | Кроме события протокола опрос при каждой успешной отправке шлёт старое сообщение { webask: true, action: 'answer' } |
Отличать по маркеру: у протокола ключ __webask, у старого — webask |
| В приложении мертвы шаринг и ссылки автора | Эти переходы идут через window.open, а он в WebView без делегата не делает ничего |
Реализовать делегат, открывающий адрес наружу |
| На сайте не поднимается оплата или не копируется код ошибки | Фрейму не выданы разрешения браузера | Поставить фрейму allow с оплатой, буфером обмена, полноэкранным режимом и автовоспроизведением |
| В приложении не работает загрузка файла | WebView не разрешил выбор файла | Реализовать обработчик выбора файла (onShowFileChooser на Android) |
| В приложении не сохраняется прогресс | WebView запрещает хранение данных страницы | Включить хранилище: domStorageEnabled на Android, персистентный websiteDataStore на iOS |
| Тестовый прогон обработали как настоящий | Не проверили ctx.preview: события предпросмотра по форме неотличимы от настоящих — они и обязаны быть неотличимы |
Отсекать поток по этой пометке на входе обработчика |
15. Чего в версии 1 нет
Список закрытый и без сроков: он отвечает на вопрос «на что нельзя рассчитывать сегодня», а не «что будет дальше». Планов публиковать пакет в документации нет. Но пункты внутри списка разной природы, и путать их дорого:
Не появится — это доступ, а не сведения. Значения ответов, промокод, ссылки на правку и просмотр ответа, хеш прохождения. Здесь дело не в объёме работ: отдать их значит отдать полномочия в чужом ответе, и никакое право этого не откроет.
Нет в версии 1 — появление было бы аддитивным. Мобильный пакет, транспорт под React Native, safe-area, auto-resize, выбор языка хостом, программный переход по экранам, офлайн-очередь. Совместимость такие добавления не ломают, поэтому закладываться на них заранее не нужно — и обещать их тоже нельзя.
- Готового SDK для мобильных. Есть описание и рабочий пример, библиотеки для установки нет — ни под iOS, ни под Android, ни под React Native.
- Идентификатора ответа. Аналога
responseIdнет ни в payload'ах, ни вctx. Сцепку это не блокирует — она делается через скрытую переменную адреса, см. следующий раздел. - SSO во встроенном опросе. Попап открывает опрос, а в нативном WebView
window.openпо умолчанию не делает ничего: опрос под SSO в приложении не проходится. - Транспорта под React Native — включается переходником, см. выше.
- Протокола на роуте правки ответа и возврата к правке кнопкой хоста: команды для этого нет, а адрес правки хосту не отдаётся.
- Safe-area и клавиатуры (
host.set-viewport). Ограничение чувствуется только у приложений, которые сознательно тянут WebView под вырез или сжимают его под клавиатуру. - Auto-resize по высоте контента — пока опрос занимает весь вьюпорт, потребителя нет.
- Выбора языка встроенного опроса хостом, программного перехода по экранам
(
host.navigate), передачи скрытых полей командой (они передаются параметрами адреса), ссылок автора в нативном WebView (перехватываются на уровне WebView). - Офлайн-режима и очереди отправки ответов.
- Event-таргетинга — это был бы отдельный контракт поверх этого протокола.
Android: проверяйте на своём билде
Поведение встраивания на Android определяет версия системного WebView, а она обновляется отдельно от операционной системы: одно и то же приложение на двух устройствах с одной версией Android может вести себя по-разному. Поэтому интеграцию стоит прогнать на своём билде и на том парке устройств, который у вас реально есть, а не только в эмуляторе.
16. Где брать результаты
Из результата протокол передаёт ровно две вещи, и обе по праву
results: балл и категорию-победитель,
объектом completed.result. Больше ничего, и это не
пробел версии 1. Всё остальное забирается там, где авторизация настоящая, — в аккаунте, которому опрос принадлежит:
через API ответов,
экспорт или
webhooks.
Как сопоставить ответ со своим пользователем
Приём один, и он целиком в вашей власти: положите свой идентификатор в адрес опроса скрытой переменной и найдите его рядом с ответом в выгрузке.
https://webask.io/6f2b1c8e-0000-4000-8000-000000000000?user_id=12345
Требования к значению — три, и все обязательны:
- постоянное для этого респондента. Набор скрытых переменных — это ещё и идентичность прохождения: меняете значение между загрузками — стираете респонденту прогресс;
- непрозрачное. Адрес виден респонденту и правится им же, поэтому кладите свой внутренний идентификатор, а не персональные данные;
- не служебное имя.
loader_bg,loader_spinner,oidc,_rзаняты.
Сопоставление — удобство, а не доказательство
Значение переменной приходит из адреса, то есть подставить туда чужое может кто угодно. Начисление бонусов, выдачу промокодов и отметку «опрос пройден» стройте на серверной стороне — по тому же правилу, по которому события протокола не годятся в доказательства.
Где это значение всплывёт на сервере: рядом с ответом в выгрузке и в теле webhook-уведомления о новом ответе — по нему и сшивайте со своим пользователем на своей стороне.
Что из протокола для сопоставления всё же полезно:
ctx.virtualId и
ctx.versionId — какой опрос и какая его версия
отдали событие. О конкретном ответе они не говорят ничего.
Коротко: чек-лист интеграции
- Слушатель — до вставки фрейма; мост — до загрузки адреса.
- Здороваться на каждый первый хендшейк документа, отслеживая
ctx.instanceId. - Адресат команд —
event.originпроверенного сообщения, никогда из тела.'*'запрещён с одним исключением: у документа в песочнице (event.origin === 'null') другого адресата не существует. - Права — только те, что обрабатываете.
navigateобязывает увести человека за 1200 мс. ctx.previewотсекать первой строкой обработчика.- Неидемпотентное — только на события без
replay. - Закрытие в два шага, предохранитель 500 мс. Не закрывать во время оплаты.
- Не закрывать по
survey.completed. - Адрес опроса стабилен: никаких кэш-бастеров.
- Незнакомые
typeи поля — игнорировать молча.
Как узнать, что протокол обновился. Новые события и поля анонсируются в разделе
«Последние обновления» —
сверяться стоит там, а не по факту прихода незнакомого
type. На проводе версию видно сразу: маркер
__webask в конверте, а
buildId и
appVersion в payload'е хендшейка называют релиз
рендерера — их стоит логировать, по ним диагностируется рассинхрон.
Протокол растёт аддитивно: новые события и опциональные поля обратную совместимость не ломают. Версия протокола
стоит маркером __webask в каждом конверте — сейчас
это 1. Хост, который умеет только версию 1, обязан
объявлять min: 1, max: 1: это не формальность, а
способ не отключить себе то, что уже работает.