Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Введение
Привет, друзья!
Представляю вашему вниманию результаты небольшого исследования, посвященного очистке данных, хранящихся на стороне клиента по сигналу сервера. Речь идет об относительно новом HTTP-заголовке Clear-Site-Data
. Также в этой статье мы немного поговорим про карту импортов (imports map).
Статья состоит из двух частей: теоретической и практической.
В теоретической части мы кратко рассмотрим карту импортов и более подробно Clear-Site-Data
.
В практической части мы поднимем два сервера — один будет запускаться локально и, помимо прочего, обслуживать статические файлы нашего приложения, другой мы развернем на Heroku
. Сначала мы запросим данные (включая куки) от серверов, сохраним эти данные в браузере с помощью трех наиболее популярных механизмов (локальное хранилище, индексированная база данных и интерфейс кеширования), затем попробуем очистить их с помощью заголовков Clear-Site-Data
. Для разрешения путей импортируемых в приложении модулей мы будем использовать карту импортов.
Исходный код проекта находится здесь.
Ресурсы
О механизмах для хранения данных на стороне клиента, которые мы будем использовать в приложении, я рассказывать не буду. Вот ссылки на отличные ресурсы, посвященные каждому из них:
- LocalStorage, SessionStorage — javascript.ru
- IndexedDB — javascript.ru
- Краткое руководство по Cache API — web.dev
- HTTP-кеширование — MDN
- Уменьшение количества сетевых запросов с помощью HTTP-кеширования — web.dev
Несмотря на то, что HTTP-кеширование — тема очень интересная, мы с ним работать не будем. Масштаб нашего приложения не позволит в должной мере оценить преимущества, предоставляемые данным механизмом. Кроме того, в конечном счете, даже при наличии соответствующих заголовков (Cache-Control
, Etag
и др.), браузер сам решает, что и как кешировать.
Инструменты
При разработке приложения мы будем использовать несколько инструментов, основными из которых являются следующие:
- very-simple-fetch — утилита, упрощающая выполнение HTTP-запросов с помощью
Fetch API
- idb — утилита, упрощающая работу с
IndexedDB
- express — фреймворк для создания серверов на
Node.js
- cors — утилита для работы с CORS-заголовками
- nodemon — утилита для запуска сервера для разработки и его автоматического перезапуска при необходимости
- open-cli — утилита для автоматического открытия вкладки браузера по указанному адресу
Теория
Карта импортов
- Документация предложения на GitHub
Карта импортов (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
- Спецификация
- MDN
- OWASP
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
origin
— заголовокAccess-Control-Allow-Origin
credentials
— заголовокAccess-Control-Allow-Credentials
allowHeaders
— заголовокAccess-Control-Allow-Headers
Настройки куки
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
и не очень понятное поведение браузеров по очистке кешированных данных, говорить о возможности его использования в продакшне также преждевременно.
Пожалуй, это все, чем я хотел с вами сегодня поделиться.
Благодарю за внимание и хорошего дня!