Введение


Привет, друзья!


Представляю вашему вниманию результаты небольшого исследования, посвященного очистке данных, хранящихся на стороне клиента по сигналу сервера. Речь идет об относительно новом HTTP-заголовке Clear-Site-Data. Также в этой статье мы немного поговорим про карту импортов (imports map).


Статья состоит из двух частей: теоретической и практической.


В теоретической части мы кратко рассмотрим карту импортов и более подробно Clear-Site-Data.


В практической части мы поднимем два сервера — один будет запускаться локально и, помимо прочего, обслуживать статические файлы нашего приложения, другой мы развернем на Heroku. Сначала мы запросим данные (включая куки) от серверов, сохраним эти данные в браузере с помощью трех наиболее популярных механизмов (локальное хранилище, индексированная база данных и интерфейс кеширования), затем попробуем очистить их с помощью заголовков Clear-Site-Data. Для разрешения путей импортируемых в приложении модулей мы будем использовать карту импортов.


Исходный код проекта находится здесь.


Ресурсы


О механизмах для хранения данных на стороне клиента, которые мы будем использовать в приложении, я рассказывать не буду. Вот ссылки на отличные ресурсы, посвященные каждому из них:



Несмотря на то, что HTTP-кеширование — тема очень интересная, мы с ним работать не будем. Масштаб нашего приложения не позволит в должной мере оценить преимущества, предоставляемые данным механизмом. Кроме того, в конечном счете, даже при наличии соответствующих заголовков (Cache-Control, Etag и др.), браузер сам решает, что и как кешировать.


Инструменты


При разработке приложения мы будем использовать несколько инструментов, основными из которых являются следующие:


  • very-simple-fetch — утилита, упрощающая выполнение HTTP-запросов с помощью Fetch API
  • idb — утилита, упрощающая работу с IndexedDB
  • express — фреймворк для создания серверов на Node.js
  • cors — утилита для работы с CORS-заголовками
  • nodemon — утилита для запуска сервера для разработки и его автоматического перезапуска при необходимости
  • open-cli — утилита для автоматического открытия вкладки браузера по указанному адресу

Теория


Карта импортов



Карта импортов (imports map) позволяет использовать так называемые голые спецификаторы импорта (bare import specifiers) в инструкциях import и выражениях import() без участия сборщиков типа Webpack или других инструментов для разрешения путей импортируемых модулей во время выполнения кода.


Предположим, что в нашем проекте используются библиотека lodash и утилита very-simple-fetch:


yarn add lodash very-simple-fetch

Для того, чтобы импортировать эти модули без помощи "бандлера", необходимо указать полный путь к соответствующим файлам, хранящимся в директории node_modules:


// допустим, что наш `script.js` находится на одном уровне с директорией `node_modules`
import { curry } from '/node_modules/lodash-es/lodash.js'
import simpleFetch from '/node_modules/very-simple-fetch/index.js'

Карта импортов позволяет связать кастомные ключи — названия модулей — с их расположением. Для этого в теге <script> с типом importmap определяется объект с ключом imports и парами ключ / значение, где значение — это путь к модулю, а ключ — синоним (алиас) для этого пути:


<script type="importmap">
{
 "imports": {
   "lodash": "/node_modules/lodash-es/lodash.js",
   "very-simple-fetch": "/node_modules/very-simple-fetch/index.js"
 }
}
</script>

После определения карты импортов, у нас появляется возможность импортировать наши модули следующим образом:


import simpleFetch from 'very-simple-fetch'

import('lodash').then(({ curry }) => {
 // ...
})

Карты импортов также предоставляют много других интересных возможностей. К сожалению, в настоящее время они поддерживаются только Chrome. Если вы пользуетесь другим агентом, при разработке приложения в практической части статьи импортируйте модули напрямую из node_modules.


Clear-Site-Data



HTTP-заголовок Clear-Site-Data позволяет передавать браузеру инструкции по очистке хранящихся на стороне клиента данных.


Данный заголовок принимает следующие директивы:


  • "storage" — указывает, что сервер желает очистить все хранилища DOM, связанные с источником (протокол, домен и порт) ответа на HTTP-запрос. К таким хранилищам относится следующее:
  • localStorage — вызывается метод localStorage.clear()
  • sessionStorage — вызывается метод sessionStorage.clear()
  • IndexedDB — для каждой базы данных запускается метод deleteDatabase()
  • регистрация сервис-воркеров — для каждого зарегистрированного СВ запускается метод unregister()
  • AppCache
  • БД WebSQL
  • данные FileSystem API
  • данные плагинов
  • "cache" — сообщает браузеру, что сервер хочет очистить все кешированные данные (как выяснилось, речь идет об HTTP-кеше, а не об интерфейсе кеширования — Cache API), связанные с источником ответа на запрос. В зависимости от браузера, он может привести к очистке предварительно отрендеренных страниц, кешей скриптов, кешей шейдеров WebGL и вариантов автозаполнения для строки поиска
  • "cookies" — сообщает браузеру, что сервер хочет удалить все куки, связанные с источником ответа на запрос (как для домена, так и для его поддоменов). Данные для аутентификации также удаляются
  • "executionContexts" — указывает, что сервер желает перезагрузить все контексты браузера, связанные с источником ответа на запрос
  • "*" — сообщает браузеру, что сервер хочет очистить все данные, связанные с источником ответа на запрос

Директивы могут указываться как по одной:


Clear-Site-Data: "storage"

так и через запятую:


Clear-Site-Data: "storage", "cookie", "cache", "executionContexts"

Последний пример аналогичен следующему:


Clear-Site-Data: "*"

К сожалению, в настоящее время данный заголовок не поддерживается Safari (ох уж этот современный IE :)).


Вот как это должно работать в теории. Скоро мы выясним, что на практике это работает немного по-другому, а кое-что и вовсе не работает.


Практика


Фронтенд и локальный сервер


Создаем директорию для проекта, переходим в нее, инициализируем проект и устанавливаем зависимости:


mkdir clear-site-data
cd !$

yarn init -y
#or
npm init -y

yarn add cors express idb nodemon open-cli very-simple-fetch
# or
npm i ...

Создаем файл server.js для локального сервера и директорию public для статических файлов, а в ней файлы index.html, style.css и script.js:


touch server.js

mkdir public
cd !$

touch index.html style.css script.js

Не забудьте создать файл .gitignore с node_modules.


Начнем с public/index.html. Создаем контейнер для UI и секцию с кнопками для взаимодействия с локальным сервером:


<main class="container">
 <section class="localhost">
   <h2>Localhost</h2>
   <div class="buttons">
     <button data-action="clear-storage" class="btn btn-primary">
       Clear storages
     </button>
     <button data-action="clear-cookies" class="btn btn-info">
       Clear cookies
     </button>
     <button data-action="clear-cache" class="btn btn-success">
       Clear HTTP cache
     </button>
     <button data-action="clear-executionContexts" class="btn btn-warning">
       Reload contexts
     </button>
     <button data-action="clear-*" class="btn btn-danger">
       Clear all site data
     </button>
   </div>
   <p></p>
 </section>
</main>

Обратите внимание на атрибуты data-action кнопок. Это небольшая хитрость позволит нам сильно упростить и сократить код скрипта. А по классам, вы, наверное, догадались, какой CSS-фреймворк мы используем для стилизации.


Добавляем карту импортов для модулей very-simple-fetch и idb:


{
 "imports": {
   "very-simple-fetch": "/node_modules/very-simple-fetch/index.js",
   "idb": "/node_modules/idb/build/esm/index.js"
 }
}

Честно говоря, поиск нужного файла в директории node_modules — занятие не из приятных. К тому же приходится искать не просто основной файл, но нужную версию файла. Например, ES-модуль idb хранится в директории esm.


Подключаем наш скрипт с типом module:


<script src="script.js" type="module"></script>

С вашего позволения файл со стилями style.css я пропущу.


Переходим к public/script.js.


Давайте подумаем, что должен делать наш скрипт.


Вот мои идеи на этот счет:


  • записать данные в локальное хранилище
  • записать данные в индексированную БД
  • записать данные в кеш с помощью Cache API
  • получить куки от локального сервера
  • при нажатии кнопки отправлять на сервер запрос, в ответ на который сервер будет устанавливать заголовок Clear-Site-Data с соответствующей директивой.

Приступим к реализации (// -> — означает сигнатуру):


// импортируем утилиты
import simpleFetch from 'very-simple-fetch'
import { openDB } from 'idb'

// определяем константу для адреса сервера
const LOCALHOST_URL = 'http://localhost:3000'

// записываем данные в локальное хранилище
// -> localStorage.setItem(key, value)
localStorage.setItem('local', 'some data from localhost')

// инициализируем БД
// и создаем хранилище объектов
// -> openDB(name, version, options)
const db = openDB('db', 1, {
 upgrade(db) {
   db.createObjectStore('store')
 }
})
// и записываем в нее данные
// -> db.put(objectStore, value, key)
const writeDataInDb = async () =>
 (await db).put('store', 'some data from localhost', 'indexed')
writeDataInDb()

// записываем данные в кеш
const cacheData = async () => {
 // получаем доступ к кешу или создаем его при отсутствии
 // -> caches.open(name)
 const cache = await caches.open('cache')
 // получаем данные от localhost и кешируем их
 // метод `add()` отправляет запрос и записывает ответ на него в кеш
 // -> cache.add(url)
 cache.add(`${LOCALHOST_URL}/get-data-for-cache`)
}
cacheData()

// получаем ссылки на DOM-элементы
const boxLocalhost = document.querySelector('.localhost')
const msgLocalhost = boxLocalhost.querySelector('p')

// функция для выполнения операции
const runAction = async (url) => {
 // получаем данные
 const { data } = await simpleFetch.get(url)
 // и возвращаем сообщение
 return data.message
}

// регистрируем обработчик для отправки запросов к localhost
boxLocalhost.addEventListener('click', ({ target }) => {
 // такой способ определения того, что целью клика является кнопка,
 // является не очень надежным, но нам он подходит, so...
 if (target.localName !== 'button') return

 runAction(`${LOCALHOST_URL}/${target.dataset.action}`)
   .then((message) => {
     // рендерим сообщение
     msgLocalhost.textContent = message
   })
})

Обратите внимание на то, как мы формируем URL запроса. Мы добавляем к адресу сервера значение атрибута data-action кнопки. Это первая половина хитрости.


Теперь займемся сервером (server.js).


Что он должен делать?


Я хочу, чтобы он делал следующее:


  • обслуживал статические файлы из директории public
  • отвечал статусом 200 на запрос "фавиконки" :)
  • разрешал импортировать модули с помощью карты импортов
  • возвращал данные для кеша
  • передавал куки
  • возвращал ответ с сообщением и заголовком Clear-Site-Data с соответствующей директивой.

Реализация:


const express = require('express')
const app = express()

// директория со статическими файлами
app.use(express.static('public'))

// решаем проблему с отсутствующей фавиконкой
app.get('/favicon.ico', (_, res) => {
 res.sendStatus(200)
})

// импорт модулей + куки
app.get('/node_modules/*', (req, res) => {
 // куки
 res.cookie('cookie_localhost', 'Do_you_want_some_cookies?', {
   // это сохранит `?`
   encode: encodeURI
 })
 res.sendFile(`${__dirname}${req.url}`)
})

// данные для кеша
app.get('/get-data-for-cache', (_, res) => {
 res.send('data for cache from localhost')
})

// ответ с сообщением и заголовком `Clear-Site-Data` с соответствующей директивой
app.get('/*', (req, res) => {
 const type = req.url.split('-')[1]

 if (!type) return res.sendStatus(400)

 res.set('Clear-Site-Data', `"${type}"`)
 res.json({
   message: `Data for localhost has been removed from ${type}`
 })
})

// поехали!
app.listen(3000, () => {
 console.log('Server ready ')
})

Мы передаем клиенту куки вместе с загружаемыми модулями. Получается, что мы делаем это дважды, но это не критично. Поскольку у нас один домен и названия куки совпадают, мы в итоге получим только одно куки.


Обратите внимание на то, как мы извлекаем тип операции — директиву для Clear-Site-Data — из тела запроса. Мы разбиваем строку в массив по символу - и извлекаем второй элемент (элемент по индексу 1). Таким образом, если сервер получил clear-storage, то типом операции (директивой) будет storage.


Также обратите внимание на то, что директива должна быть закавычена, причем кавычки обязательно должны быть двойными (").


В сообщении мы просто указываем, что данные определенного типа для localhost были удалены.


Пришло время запустить сервер и убедиться в том, что все работает.


Добавляем в файл package.json команду для запуска сервера для разработки:


"scripts": {
 "dev": "open-cli http://localhost:3000 && nodemon server.js"
}

Выполняем эту команду в терминале:


yarn dev
# or
npm run dev

Данная команда запускает сервер для разработки и открывает вкладку браузера по адресу http://localhost:3000.


Открываем инструменты разработчика, переходим в раздел Application (“Приложение”) и проверяем, что все наши данные успешно сохранены в браузере:


  • Local Storage
  • IndexedDB
  • Cookies
  • Cache Storage









Нажимаем кнопку Clear storages. Получаем сообщения Data for localhost has been removed from storage от сервера и Clear-Site-Data header on 'http://localhost:3000/clear-storage': Cleared data types: "storage". от браузера:




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


Нажатие кнопки Clear cookies приводит к удалению куки:




Нажатие кнопки Clear HTTP cache, вероятно, приводит к удалению HTTP-кеша:




Кажется, что все хорошо, однако нажатие кнопки Reload contexts приводит к возникновению ошибки:




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


Дело в том, что директива "executionContexts" в настоящее время поддерживается только Samsung Internet, т. е. можно сказать, что не поддерживается. В сети можно найти информацию о том, что данная директива, скорее всего, будет удалена из спецификации.


Дальше интересней: нажатие кнопки Clear all site data также приводит к ошибке:




Хотя должно приводить к очистке данных всех типов.


Здесь мы имеем дело с багом Chrome. Вот все, что мне удалось найти по данному багу. Кажется, в ближайшее время никто не собирается его фиксить.


В Firefox это работает:




Из всего этого можно сделать следующий вывод: сегодня в браузерах Chrome и Firefox можно безопасно использовать только директивы "storage", "cookies" и "cache". В принципе, для очистки данных всех типов в Chrome можно указать все названные директивы через запятую: это будет иметь такой же эффект, что и использование директивы *.


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


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


Удаленный сервер


Начнем с самого сервера. Создаем для него директорию, переходим в нее, инициализируем проект и устанавливаем зависимости:


mkdir heroku
cd !$

yarn init -y
# or
npm init -y

yarn add cors express
# or
npm i ...

Определяем команду для запуска сервера в package.json:


"scripts": {
 "start": "node index.js"
}

Создаем файл index.js следующего содержания:


const express = require('express')
const app = express()
// утилита для установки CORS-заголовков
const cors = require('cors')

// см. ниже
app.use(
 cors({
   origin: 'http://localhost:3000',
   credentials: true,
   allowedHeaders: 'Content-Type'
 })
)

app.get('/favicon.ico', (_, res) => {
 res.sendStatus(200)
})

// данные для кеша
app.get('/get-data-for-cache', (_, res) => {
 res.send('data for cache from heroku')
})

// куки — см. ниже
app.get('/get-cookie', (_, res) => {
 res.cookie('cookie_heroku', 'Do_you_want_some_cookies?', {
   encode: encodeURI,
   sameSite: 'none',
   secure: true
 })
 res.send('Here is you cookie!')
})

// остальной код аналогичен коду локального сервера
app.get('/*', (req, res) => {
 const type = req.url.split('-')[1]

 if (!type) return res.sendStatus(400)

 res.set('Clear-Site-Data', `"${type}"`)

 res.json({
   message: `Data for heroku has been removed from ${type}`
 })
})

const PORT = process.env.PORT || 5000
app.listen(PORT, () => {
 console.log('Server ready ')
})

Настройки cors



Настройки куки


  • sameSite — директива SameSite определяет, могут ли куки передаваться между разными источниками
  • secure — директива secure определяет, что куки должны передаваться только по HTTPS

Без этих настроек и еще одной на клиенте мы не сможем получить куки от "удаленного" сервера.


Деплой сервера


Для того, чтобы иметь возможность разворачивать приложения на Heroku, необходимо создать там аккаунт, а также глобально установить heroku-cli:


yarn global add heroku
# or
npm i -g heroku

Инициализируем репозиторий и добавляем в него файлы приложения:


git init
git add .
git commit -m "create app"

Создаем проект на Heroku:


heroku create

Проверяем, что наш проект привязан к Heroku-проекту, и отправляем файлы:


git remote -v
git push heroku master

Готово.


Полную инструкцию по деплою приложения на Heroku можно найти здесь.


Проверять работоспособность приложения по автоматически сгенерированной ссылке (например, в моем случае это https://hidden-sands-68187.herokuapp.com) особого смысла не имеет, но можете это сделать, если хотите: перейдите по ссылке, откройте инструменты разработчика, вставьте в консоль fetch('/clear-storage').then((response) => response.json()).then(({ message }) => console.log(message)) и нажмите Enter. Если после этого вы получили сообщение Data for heroku has been removed from storage, значит, вы все сделали правильно.


Добавляем в public/index.html раздел с кнопками для взаимодействия с удаленным сервером:


<section class="heroku">
 <h2>Heroku</h2>
 <div class="buttons">
   <button data-action="clear-storage" class="btn btn-primary">
     Clear storages
   </button>
   <button data-action="clear-cookies" class="btn btn-info">
     Clear cookies
   </button>
   <button data-action="clear-cache" class="btn btn-success">
     Clear HTTP cache
   </button>
 </div>
 <p></p>
</section>

И вносим несколько изменений в public/script.js:


// у вас будет другой адрес
const HEROKU_URL = 'https://hidden-sands-68187.herokuapp.com'

const cacheData = async () => {
 // ...
 // получаем данные для кеширования от heroku
 cache.add(`${HEROKU_URL}/get-data-for-cache`)
}

// получаем куки с heroku
const getCookie = async () => {
 const { data } = await simpleFetch.get(`${HEROKU_URL}/get-cookie`, {
   // эта настройка является обязательной
   credentials: 'include'
 })
 console.log(data)
}
getCookie()

const boxHeroku = document.querySelector('.heroku')
const msgHeroku = boxHeroku.querySelector('p')

// регистрируем обработчик для отправки запросов к heroku
boxHeroku.addEventListener(
 'click',
 ({
   target: {
     dataset: { action }
   }
 }) => {
   if (!action) return

   runAction(`${HEROKU_URL}/${action}`).then((message) => {
     msgHeroku.textContent = message
   })
 }
)

Перезапускаем сервер для разработки и видим в консоли сообщение от удаленного сервера:




В разделе Application находим кешированные данные и куки от heroku:






Нажимаем на кнопку Clear storages, получаем сообщения об очистке хранилищ браузера, но данные из локального хранилища, индексированной БД и кеша (!) при этом не удаляются. С локальным хранилищем и БД все понятно, они принадлежат localhost, но данные в кеше, полученные от heroku, должны были удалиться:




И это не баг Chrome (или баг не только Chrome), точно такой же результат мы получаем в Firefox:




Директива * также не удаляет кешированные данные, полученные от heroku:




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


Нажимаем на кнопку Clear cookies, получаем сообщения об удалении куки, и куки от heroku благополучно удаляется:




Вывод


Итак, что мы имеем в сухом остатке?


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


Что касается заголовка Clear-Site-Data, то, в целом, он неплохо справляется со своей задачей, однако тот факт, что он не поддерживается Safari, а также учитывая баг в Chrome и не очень понятное поведение браузеров по очистке кешированных данных, говорить о возможности его использования в продакшне также преждевременно.


Пожалуй, это все, чем я хотел с вами сегодня поделиться.


Благодарю за внимание и хорошего дня!




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