Представьте, что вы отвечаете за большой веб-сервис со сложным интерфейсом, замысловатой навигацией, авторизацией, платежной системой. Представьте, что однажды к вам приходит ваш PO и просит сделать не одну его часть, не один конкретный бизнес-процесс, а весь этот сервис встраиваемым. И конечно по пути ни один из сотен тысяч пользователей вашего сервиса не должен пострадать. Возможно ли это и насколько это дорого?

Зачем?

Да, скорее всего, это первое, о чем вы подумаете, когда перед вами поставят такую задачу. Зачем это может быть нужно? Ответ прост, встраивание - самый дешевый способ интеграции. Если вы хотите, чтобы как можно больше партнёров могли использовать функционал вашего сервиса на своих страницах, вам нужно сделать его встраиваемым. В таком случае потребителю будет достаточно добавить себе на сайт пару скриптов, вызвать несколько JavaScript-методов и все, дорогостоящая разработка не потребуется.

Особенно эффективен этот подход, если ваш бизнес связан с продажей услуг в интернете, чаще всего к нему прибегают продавцы авиабилетов и сервисы аренды жилья. Крупные компании разрабатывают для встраивания специальные, урезанные версии своих приложений, заточенные под один конкретный пользовательский сценарий, чаще всего - собственно покупку основного продукта. Вот как выглядят эти приложения у ключевых игроков рынка, Anywayanyday и Booking.com.

Но что, если у вас небольшой бизнес и держать отдельную команду, которая разрабатывает встраиваемое решение вам не по карману? В таком случае вы можете придти к этой странной идее: встроить все.

Как?

Первое, о чем стоит позаботиться - тестовый стенд. Вам нужно создать веб-приложение, которое будет изображать потребителя вашего виджета. Я назову его Pseudo-partner, a встраиваемый сервис для простоты буду называть виджетом. Pseudo-partner можно будет использовать для разработки, тестирования и экспериментов. Вот основные требования:

  1. Pseudo-partner и виджет обязательно должны использовать разные ориджины. Порт тоже является частью ориджина, поэтому для локальной разработки будет достаточно просто запустить Pseudo-partner и виджет на разных портах

  2. Pseudo-partner должен уметь менять URL-адрес виджета

  3. Pseudo-partner должен быть адаптирован для мобильных устройств, особенно если ваш виджет с ними работает

  4. Будет здорово, если Pseudo-partner будет похож на настоящие партнерские сайты, где вы планируете размещать ваши виджеты

Схематично сервис Pseudo-partner выглядит примерно так:

Можно начинать экспериментировать. Создадим iframe, укажем в его src адрес нашего виджета, установим базовые настройки стилей и вставим его внутрь Pseudо-partner'a:

<iframe
    title="My application widget"
    src="https://my-application.ru"
    style="width:100%; border:none; min-height:300px;"
>
</iframe>

Фрейм займет всю доступную ширину, а вот его высота по контенту не потянется, она будет зафиксирована на значении в 300px. Всё, что не поместится во фрейм по высоте, будет скрыто за полосой прокрутки. Единственный способ заставить iframe учитывать высоту контента - организовать протокол обмена данными между родительским и встроенным окном. Для решения этой задачи существует window.postMessage API.

Уже на этом этапе понятно, что на стороне Pseudo-partner нужно уметь выполнять JavaScript код. Этот код среди всего прочего, будет получать сообщения виджета и изменять размер фрейма, поэтому просто тега iframe нам не хватит, мы будем использовать внешний скрипт и инициализирующий вызов.

Инициализация

Основная задача этого кода - предоставить JavaScript-API для потребителей нашего виджета. В самом простом случае встраивание виджета могло бы выглядеть следующим образом:

<script async src="https://some-cdn-server.net/widget/v1/script.js"></script>
<script>
   window.onWidgetCodeLoaded = () => {
      const widget = new widnow.Widget({
         container: document.querySelector('#container'),
         consumerId: 'very_first_partner'
      });
   }
</script>

Несколько важных моментов:

  1. Используйте версионирование для скрипта инициализации: /widget/v1/script.js. В будущем мы можем захотеть поменять API виджета и не сможем поддерживать обратную совместимость.

  2. Я советую не кэшировать код скрипта на долгое время, особенно на ранних стадиях разработки. Вы можете захотеть обеспечить новую функциональность, используя тот же самый файл со скриптом. Или, в худшем случае, вы можете обнаружить баг в своем коде и захотеть его исправить для всех потребителей сразу. Не настраивайте  max-age больше, чем на несколько часов, а лучше просто используйте ETag для инвалидации кэша.

  3. Используйте атрибут async для тега script или будьте готовы, что ваш потребитель будет его использовать. Чтобы поддержать асинхронную загрузку, можно использовать колбек, как в примере выше, или кастомные ивенты.

  4. Присвойте каждому из потребителей, установивших ваш виджет, уникальный id. Это поможет обеспечить безопасность, отслеживать статистику и быстрее находить, в чем и у кого проблемы.

Сам по себе скрипт инициализации может быть написан в ООП-стиле, я люблю использовать TypeScript:

interface Window {
    Widget: typeof FramedWidget;
    onWidgetCodeLoaded: () => void;
}

interface InitParams {
    container: HTMLElement;
    consumerId: string;
}

interface Message {
    type: 'height-changed';
    value: number;
}

class FramedWidget {
    constructor(params: InitParams) {
        this.init(params);
    }

    private frameOrigin = 'https://my-application.ru';

    private frame: HTMLIFrameElement | undefined;

    private parseMessage(message: string) {
        let result: Message | undefined;

        try {
            result = JSON.parse(message);
        } catch (e) {}

        return result;
    }

    private postMessageHandler = (event: MessageEvent<string>) => {
        if (event.origin !== this.frameOrigin) {
            return;
        }

        const message = this.parseMessage(event.data);

        if (message?.type === 'height-changed') {
            this.frame!.style.height = `${message.value}px`;
        }
    };

    private init(params: InitParams) {
        this.frame = document.createElement('iframe');
        this.frame.src = `${this.frameOrigin}?consumerId=${params.consumerId}`;
        this.frame.setAttribute(
          'style',
          'width:100%; border:none; min-height:300px;'
        );

        params.container.appendChild(this.frame);

        window.addEventListener('message', this.postMessageHandler);
    }

    destroy = () => {
        window.removeEventListener('message', this.postMessageHandler);
    };
}

if (typeof window !== 'undefined') {
    window.Widget = FramedWidget;
    window.onWidgetCodeLoaded?.();
}

Из важного здесь:

  1. Сразу позаботьтесь о методе destroy, он будет особенно полезен при встраивании виджета в SPA-приложение

  2. Всегда проверяйте event.origin внутри postMessageHandler, и заворачивайте ваш JSON.parse код внутрь блоков try ... catch

  3. Установите значение min-height вашего фрейма на какое-то осмысленное значение, это уменьшит количество "скачков" интерфейса и позволит нарисовать ошибку внутри фрейма, если что-то в процессе инициализации пойдет не так.

  4. Собирайте этот код под максимально широкий диапазон браузеров. Я советую устанавливать "target": "es6" в tsconfig.json или что-то около > 0.2%, not dead в .browserslistrc.

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

Этот код, хоть и очень прост, уже неплохо работает и решает ряд важных задач: обеспечивает вполне вразумительное API, асинхронную инициализацию, поддерживает ресайз контента внутри фрейма. Кажется, что все начинает получаться, пока мы не пробуем перейти по первой же внешней ссылке, например, в социальные медиа. Да, страница вашего сервиса ВКонтакте тоже откроется внутри фрейма. Похоже настало время перепридумать навигацию основного сервиса.

Навигация

Ваш виджет должен поддерживать четыре типа переходов между страницами:

  1. Настоящие ссылки, которые должны открываться внутри фрейма. Это самый простой кейс, просто используйте target="self"и все будет работать как надо

  2. Настоящие ссылки, которые должны открываться в родительском окне, например, переходы в социальные сети. Это тоже просто, используйте для них target="top".

  3. Переходы, которые вы хотите выполнять через JavaScript внутри фрейма через изменение window.location.href или вызов history.pushState. Они должны работать нормально без доработок.

  4. Самый сложный кейс - переходы, которые вы хотите выполнить через JavaScript в родительском окне. Единственный способ это сделать - вызов window.postMessage.

Проблема четвертого пункта еще и в том, что такое поведение нельзя сделать универсальным. Чтобы все работало правильно, на каждой странице вашего основного приложения вам нужно знать, выполняется ли оно внутри фрейма или независимо. Получить этот флаг в браузере довольно просто:

const isInFrame = window !== window.parent;

Если ваше приложение работает только в браузере, это все, что вам потребуется. Однако если вы используете, например, серверный рендеринг, задача сильно усложняется. Невозможно по HTTP-запросу понять, посылается ли он изнутри iframe или родительским окном, нет никаких специальных заголовком, браузер никак на это не указывает. Единственный способ - использовать для встраивания отделыные урлы, тут тоже есть несколько вариантов:

  1. Завязаться на квери-параметры, например, подклеивать ?inFrame=1 ко всем ссылкам приложения, когда оно используется во фрейме

  2. Использовать отдельный субдомен, например, framed.my-application.ru. Это более правильный путь, потому что он меньше затрагивает логику приложения и его проще поддерживать. Главное тут всегда явно указывать домен при выставлении пользовательских кук и не забывать про ограничения в local/session storage, данные оттуда не будут шариться между субдоменами.

@pae174 в комментариях справедливо указывает, что на самом деле браузеры посылают Sec-Fetch-Dest заголовок, который, хоть и не кроссбраузерно, но однозначно позволяет определить, что ваши страницы запрашивают изнутри фрейма.

Возможность проверить режим отображения на каждой странице приложения открывает новые возможности. Например, вы можете спрятать шапку или футер во фрейме или изменить контактную информацию, чтобы ваша служба поддержки знала, что имеет дело с пользователем виджета.

Наконец приложение работает внутри и вне фрейма, оно адаптивно, в нем можно использовать все виды ссылок. Функционирует даже авторизация, куки прокидываются из родительского окна в виджет, работающий во фрейме. Ну то есть почти прокидываются. Ну то есть совсем не прокидываются в Safari.

Авторизация

Причина, по которой авторизация не будет работать в Safari - политика блокировки сторонних кук. Safari не передаст куки в кросс-доменные фреймы, а это значит что ваш виджет в Safari не получит доступ ни к одной из кук, установленных для вашего домена. И это политика не только Safari, а всего движка Webkit.

Поверьте, я перепробовал множество способов обойти это ограничение, но единственный рабочий вариант здесь - подчиниться требованиям, то есть использовать Storage Access API. Вам необходимо запросить у пользователя доступ к данным, хранящимся в браузере, причем сделать это можно только после пользовательского взаимодействия с вашим сервисом, например, после клика.

Самый простой способ выглядит следующим образом:

  1. Если при загрузке приложения пользователь не авторизован - выполните проверку наличия доступа к данным пользователя: document.hasStorageAccess()

  2. Если полученный промис зарезолвится в false - покажите пользователю страницу с объяснением, что такое storage access, о чем конкретно вы просите и какие данные получите

  3. После получения доступа через вызов document.requestStorageAccess(), продолжите стандартный процесс авторизации

  4. Storage access будет выдан на весь ориджин, после чего document.hasStorageAccess() будет резолвится в true. Период, на который выдается доступ может, отличатся в зависимости от браузера, да и весь API может в будущем претерпеть изменения, это все еще драфт.

Очень упрощенно в коде это можно записать следующим образом:

const checkStorageAccess = async (): Promise<boolean> => {
    if (document.hasStorageAccess) {
        return document.hasStorageAccess();
    }

    return true;
}

const requestStorageAccess = async () => {
    const hasStorageAccess = await checkStorageAccess();

    if (hasStorageAccess) {
        proceedToAuthorization();
        return;
    }

    return document.requestStorageAccess()
        .then(() => {
            proceedToAuthorization();
        })
        .catch(() => {
            proceedToErrorPage()
        })
}

document
    .querySelector('.grant-access-button')
    .addEventListener('click', requestStorageAccess);

Еще один важный шаг позади - в виджете заработала авторизация. Осталось подумать о безопасности.

Безопасность

С точки зрения безопасности главное для вас - контролировать страницы, которые имеют возможность встраивать ваш виджет. Для этого в заголовке Content-Security-Policy предназначена директива frame-ancestors. Я советую сделать следующее:

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

  2. Используйте для всех доменов в директиве frame-anсestors только https://

  3. Проверяйте event.origin для всех событий message

  4. Для хранения авторизационных данных используйте исключительно куки с флагами Secure и HttpOnly

  5. Я бы не советовал устанавливать для фрейма какое-либо значение атрибута sandbox


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

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

Последняя рекомендация тем, кто пойдёт по этому пути - сделайте тестирование виджета отдельной частью e2e-пайплайна, активно использовать для тестирования виджета скриншот-тесты и не забывайте проверять любой новый функционал и в виджете тоже. Главное ограничение этого решения содержится в исходной постановке задачи - вы не сможете деплоить основное приложение и виджет отдельно.

Комментарии (6)


  1. pae174
    21.11.2022 14:00
    +1

    Невозможно по HTTP-запросу понять, посылается ли он изнутри iframe или родительским окном, нет никаких специальных заголовком, браузер никак на это не указывает.

    У вас слегка устаревшая информация. Браузеры очень даже указывают, но не все.

    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Dest

    https://caniuse.com/?search=SEC-FETCH-DEST


    1. javar Автор
      21.11.2022 16:09

      Да, спасибо большое, что упомянули Fetch-Metadata, это важное пояснение!

      Я бы не стал завязываться на него в коде приложения, все таки его роль совсем другая, но важно знать, что он есть! Поправил в тексте статьи, спасибо!

      Давайте я еще вот эту статью приложу, тут, мне кажется, лучше всего разъясняется специфика: https://web.dev/fetch-metadata/


  1. tolik_anabolik
    22.11.2022 00:51

    Не совсем понятен мотив использовать iframe для интеграции своего сервиса с партнерами. Не проще ли точно также добавлять партнерам ваш скрипт виджета и инициализировать виджет с указанием контейнера?


    1. javar Автор
      22.11.2022 00:52

      Не понял схему, а технически как должен быть устроен виджет? Что он из себя представляет?


      1. tolik_anabolik
        22.11.2022 13:03

        У партнера подключаем скрипт виджета widget.js, инициализируем виджет, передавая селектор контейнера на странице партнера, ключ партнера для доступа к нашему апи:

        <div id="widget_place"></div>
        ...
        <script src="https://our.service.org/v1/widget.js" nonce="..."></script>
        <script type="module">
        const app = new Widget({
          apiKey:    '...',
          container: '#widget_place',
        });
        app.init();
        </script>

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


        1. javar Автор
          22.11.2022 13:28

          Тут на сколько я понял Widget - это полноценное js-приложение, которое выполняется на стороне партнёра. Это вполне возможное решение, там есть свои ограничения, но это работает во многих кейсах. Именно так встраиваются картографические сервисы, например.

          Мне этот подход не подходил так как я не хотел писать отдельное приложение для партнёров, я хотел просто научить текущее встраиваться. И тут уже кроме iframe вариантов особых нет.