Версионирование статических файлов через граф зависимостей

Версия статического файла может измениться без правки его исходника. Если имя картинки содержит хеш её содержимого, новая картинка получит новый адрес, а CSS — новую ссылку на неё. Изменятся и байты готового CSS. Поэтому хеш каждого ресурса нужно считать после подстановки адресов зависимостей: связи между файлами определяют порядок сборки.

Учебные примеры подготовлены в октябре 2026 года. В них использованы современные возможности JavaScript и платформы; архивная дата не относится к этим реализациям.

Мне нравится, когда сборка сама определяет этот порядок по ссылкам между файлами. Правило «сначала картинки, потом стили» кажется достаточным, пока один CSS не подключит другой или модуль JavaScript не импортирует соседний модуль. У каждого файла сначала должны быть готовы те ресурсы, на которые он ссылается.

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

Хеш исходника и хеш результата

Пусть в исходном CSS есть такая ссылка:

.status {
  background-image: url('../i/mark.svg');
}

После сборки вместо mark.svg будет имя, зависящее от содержимого картинки. Если вычислить хеш CSS до этой замены, он опишет исходник. Браузер получит другие байты.

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

Для хеширования возьмём SHA-256 из стандартного модуля Node.js. В имени оставим все 64 шестнадцатеричных знака. Так учебному сборщику не понадобятся отдельное правило сокращения и обработка совпавших префиксов.

import { createHash } from 'node:crypto';

export function digest(content) {
  return createHash('sha256').update(content).digest('hex');
}

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

Какие ссылки мы умеем находить

Для поиска ссылок в произвольном HTML, CSS и JavaScript понадобились бы разные разборщики. Здесь я хочу сосредоточиться на порядке сборки, поэтому отмечу ссылки собственным коротким маркером:

{{asset:./css/page.css}}

Его можно поставить в значение HTML-атрибута или в строку JavaScript. В CSS он займёт место адреса внутри url(). Кавычки и остальная конструкция остаются в исходнике:

<link rel="stylesheet" href="{{asset:./css/page.css}}">
<img src="{{asset:./i/mark.svg#mark}}" width="32" height="32" alt="">
<script type="module" src="{{asset:./js/main.js}}"></script>
.status {
  padding-inline-start: 40px;
  background: url("{{asset:../i/mark.svg?theme=light#mark}}") no-repeat left center;
}
import { message } from '{{asset:./shared.js}}';

document.querySelector('.status').textContent = message;

Это именно шаблоны, которые надо собрать перед передачей браузеру. Сборщик знает только маркер. Обычный import './shared.js' он не перепишет, как не перепишет обычный url('../i/mark.svg'). Он также не отличает строку от комментария: маркер обрабатывается в любом месте текстового исходника. В этом примере каждый внутренний адрес ресурса отмечен явно; внешние ссылки остаются обычными ссылками.

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

Адрес разрешается относительно исходного файла

Ссылка ../i/mark.svg внутри /css/page.css ведёт к /i/mark.svg. Её параметры и фрагмент не входят в имя исходного файла. Две ссылки на картинку могут отличаться суффиксами, но зависимость у них одна.

Где встретилась ссылка Исходное значение Ключ зависимости
/index.html ./i/mark.svg#mark /i/mark.svg
/css/page.css ../i/mark.svg?theme=light#mark /i/mark.svg
/js/main.js ./shared.js /js/shared.js

Для разрешения относительного пути подходит URL. Временный адрес https://assets.invalid нужен только как основание для вычисления; запросов к нему не будет. На выходе используем путь, а не этот домен.

const origin = 'https://assets.invalid';

function resolve(value, owner) {
  const path =
    /^(?:\/(?!\/)|\.\.?\/)[a-zA-Z0-9_./-]+(?:\?[a-zA-Z0-9_.=%-]+)?(?:#[a-zA-Z0-9_.-]+)?$/;

  if (!path.test(value)) {
    throw new Error(`Unsupported reference in ${owner}: ${value}`);
  }

  return new URL(value, origin + owner);
}

Допустимый синтаксис здесь намеренно узкий. Путь начинается с /, ./ или ../, содержит латинские буквы и обычные знаки имени файла. Пробелы и процентное кодирование в пути не поддерживаются. В параметрах допускается одна последовательность без &, как theme=light; фрагмент может содержать буквы, цифры и знаки _.-. Внешний URL внутри маркера будет ошибкой.

Ограничение параметров избавляет этот пример от отдельного экранирования амперсанда в HTML. Кавычки и угловые скобки также не входят в допустимый адрес. Если расширять синтаксис до произвольных URL, придётся учитывать место подстановки: правила строк JavaScript и HTML-атрибутов различаются. Одного расширения регулярного выражения недостаточно.

Зависимость должна закончить сборку первой

Для нашей страницы связи выглядят так:

/index.html
  → /css/page.css
      → /i/mark.svg
  → /i/mark.svg
  → /js/main.js
      → /js/shared.js

Стрелка означает «использует адрес». Чтобы закончить /css/page.css, нужен готовый адрес картинки. Чтобы закончить HTML, нужны уже готовые адреса его ресурсов.

Хранить граф отдельным объектом необязательно. Когда встречаем маркер, сразу переходим к указанному файлу и собираем его. Готовый результат сохраняем в ready. Если другая ссылка ведёт к тому же файлу, берём результат оттуда. Так картинка, подключённая и из CSS, и из HTML, обрабатывается один раз.

Массив active хранит путь текущего обхода: файлы, к которым мы уже приступили, но ещё не закончили. Их готовых адресов пока нет. Возврат к одному из них будет означать цикл, который разберём ниже.

Ниже остальная часть bundle.mjs. К ней нужно добавить показанную выше функцию digest с импортом createHash, затем origin и resolve. Функция принимает Map, где ключом служит абсолютный путь внутри сайта, а значением являются байты исходного файла. Это небольшой доверенный набор ресурсов проекта, не обработчик пользовательских загрузок.

import { posix } from 'node:path';

const marker = /\{\{asset:([^{}\r\n]+)\}\}/g;
const textual = new Set(['.html', '.css', '.js', '.svg']);

export function bundle(sources) {
  const ready = new Map();
  const active = [];

  for (const [name, content] of sources) {
    if (!/^\/[a-zA-Z0-9_./-]+$/.test(name) || posix.normalize(name) !== name) {
      throw new Error(`Unsupported source path: ${name}`);
    }

    if (!Buffer.isBuffer(content)) {
      throw new TypeError(`Expected bytes: ${name}`);
    }
  }

  function visit(name) {
    if (ready.has(name)) {
      return ready.get(name);
    }

    const cycle = active.indexOf(name);

    if (cycle !== -1) {
      throw new Error(`Cycle: ${[...active.slice(cycle), name].join(' -> ')}`);
    }

    if (!sources.has(name)) {
      throw new Error(`Missing ${name}, referenced by ${active.at(-1)}`);
    }

    active.push(name);
    let content = sources.get(name);
    const extension = posix.extname(name);

    if (textual.has(extension)) {
      const source = new TextDecoder('utf-8', { fatal: true }).decode(content);
      const result = source.replace(marker, (_, value) => {
        const reference = resolve(value, name);
        const dependency = visit(reference.pathname);

        return dependency.url + reference.search + reference.hash;
      });

      if (result.includes('{{asset:')) {
        throw new Error(`Malformed marker in ${name}`);
      }

      content = Buffer.from(result);
    }

    const hash = digest(content);
    const parsed = posix.parse(name);
    const url =
      name === '/index.html' ? name : posix.join(parsed.dir, `${parsed.name}.${hash}${parsed.ext}`);
    const resource = { url, content, hash };

    ready.set(name, resource);
    active.pop();

    return resource;
  }

  const names = [...sources.keys()].sort();

  for (const name of names) {
    visit(name);
  }

  return new Map(names.map(name => [name, ready.get(name)]));
}

Сначала функция проверяет имена и тип входных данных. Затем visit либо берёт готовый результат, либо готовит новый. Во время замены маркера она рекурсивно собирает зависимость. Когда выполнение возвращается к digest(content), в content уже стоят окончательные адреса.

Параметры и фрагмент добавляются после обращения к зависимости. Хеш картинки от ?theme=light не меняется: в нашей модели по этому пути всегда лежат одни и те же статические байты. Если реальный обработчик меняет изображение по параметрам запроса, такое предположение не подойдёт. Тогда разные производные надо учитывать как разные ресурсы.

Текст читается как UTF-8 с отказом при некорректных байтах. Остальные расширения считаются двоичными и проходят без преобразования. Например, содержимое PNG нельзя сначала превратить в строку, а затем хешировать и записывать обратно. Мы получили бы хеш уже испорченного файла.

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

Собираем страницу

Чтобы запустить пример, создадим рядом example.mjs. Он задаёт исходные файлы прямо в коде, поэтому для эксперимента не нужен обход каталогов. Преобразование строк в Buffer происходит на входе:

export const sources = new Map(
  [
    [
      '/index.html',
      `<!doctype html>
<html lang="ru">
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Версии ресурсов</title>
  <link rel="stylesheet" href="{{asset:./css/page.css}}">
  <h1>Версии ресурсов</h1>
  <p class="status">Страница собрана.</p>
  <img src="{{asset:./i/mark.svg#mark}}" width="32" height="32" alt="">
  <script type="module" src="{{asset:./js/main.js}}"></script>
</html>
`
    ],
    [
      '/css/page.css',
      `.status {
  padding-inline-start: 40px;
  background: url("{{asset:../i/mark.svg?theme=light#mark}}") no-repeat left center;
}
`
    ],
    [
      '/i/mark.svg',
      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
  <circle id="mark" cx="16" cy="16" r="12" fill="green"/>
</svg>
`
    ],
    [
      '/js/main.js',
      `import { message } from '{{asset:./shared.js}}';

document.querySelector('.status').textContent = message;
`
    ],
    [
      '/js/shared.js',
      `export const message = 'Модуль тоже собран.';
`
    ],
    ['/i/unused.bin', Buffer.from([0, 255, 3, 128])]
  ].map(([name, content]) => [name, Buffer.from(content)])
);

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

Результат bundle ещё находится в памяти. Запишем его в новый временный каталог скриптом build.mjs:

import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { bundle } from './bundle.mjs';
import { sources } from './example.mjs';

const resources = bundle(sources);
const output = await mkdtemp(join(tmpdir(), 'asset-graph-'));

for (const resource of resources.values()) {
  const file = join(output, resource.url.slice(1));

  await mkdir(dirname(file), { recursive: true });
  await writeFile(file, resource.content, { flag: 'wx' });
}

const manifest = Object.fromEntries([...resources].map(([name, resource]) => [name, resource.url]));

await writeFile(join(output, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
console.log(output);

Команда node build.mjs напечатает каталог результата. В нём лежат готовые ресурсы и manifest.json, который сопоставляет исходные пути с новыми. Файлы записаны под именами с хешами, поэтому серверу не придётся преобразовывать такие адреса перед чтением с диска.

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

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

Проверим, совпадает ли хеш в имени с хешем готового содержимого. Заодно сравним результаты двух запусков, поменяв порядок исходников. Этот и следующие блоки с проверками сохраним по порядку в check.mjs рядом с остальными модулями; запустить их можно командой node check.mjs.

import assert from 'node:assert/strict';
import { bundle, digest } from './bundle.mjs';
import { sources } from './example.mjs';

const first = bundle(sources);
const second = bundle(new Map([...sources].reverse()));

assert.deepEqual([...first], [...second]);

for (const [name, resource] of first) {
  assert.equal(resource.hash, digest(resource.content));

  if (name !== '/index.html') {
    assert.ok(resource.url.includes(resource.hash));
  }
}

Во втором запуске я намеренно поменял порядок исходников. Сравниваем массивы записей, чтобы проверить и содержимое, и порядок: при сравнении самих Map проверка deepEqual порядок не учитывает. Зависимости всё равно обрабатываются раньше потребителей, а итоговый Map возвращается с отсортированными ключами. Такой же порядок попадёт в JSON-манифест. Здесь нет текущего времени или случайного номера, поэтому повторная сборка не меняет адреса.

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

Теперь поменяем цвет картинки.

const changed = new Map(sources);
changed.set(
  '/i/mark.svg',
  Buffer.from(sources.get('/i/mark.svg').toString().replace('green', 'red'))
);
const third = bundle(changed);

assert.notEqual(third.get('/i/mark.svg').url, first.get('/i/mark.svg').url);
assert.notEqual(third.get('/css/page.css').url, first.get('/css/page.css').url);
assert.notDeepEqual(third.get('/index.html').content, first.get('/index.html').content);
assert.deepEqual(third.get('/js/main.js'), first.get('/js/main.js'));
assert.deepEqual(third.get('/js/shared.js'), first.get('/js/shared.js'));
assert.deepEqual(third.get('/i/unused.bin'), first.get('/i/unused.bin'));

У картинки меняется собственное содержимое. У CSS меняется ссылка на неё, хотя исходник CSS остался прежним. HTML получает новые адреса в двух местах. Скрипты не используют картинку, поэтому их содержимое и адреса сохраняются.

Для приведённых исходников получаются такие начала хешей. В таблице оставлены восемь знаков для сравнения; в именах файлов по-прежнему используются все 64.

Ресурс Зелёная картинка Красная картинка
/i/mark.svg b9551293 6f1023e8
/css/page.css b6062b5a 6433c27f
/index.html ff5a4c91 01dda37e
/js/main.js 4289eea2 4289eea2

Именно эта проверка отличает зависимости от общего номера сборки. Общий номер тоже обновит адреса, но заодно заставит браузер считать новыми все ресурсы. Здесь изменения распространяются только по связям от изменившегося файла к его потребителям.

Когда обход должен остановиться

Одна картинка подключена и из HTML, и из CSS. При первом обращении visit её собирает. При следующем находит результат в ready. Это не цикл: прежняя обработка уже закончилась.

Цикл появляется, когда зависимость приводит к файлу, который всё ещё находится в active. Например, /a.js ждёт готового адреса /b.js, а тот ждёт адреса /a.js:

const cycle = new Map([
  ['/a.js', Buffer.from("import '{{asset:./b.js}}';")],
  ['/b.js', Buffer.from("import '{{asset:./a.js}}';")]
]);

assert.throws(() => bundle(cycle), /Cycle: \/a.js -> \/b.js -> \/a.js/);

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

Это ограничение выбранного способа именования. Циклические импорты сами по себе разрешены в JavaScript, хотя обращение к ещё не инициализированному значению может привести к ошибке. Нашему сборщику не удаётся назначить имена в порядке «сначала готовая зависимость». Это отдельная проблема, её не стоит объяснять запретом языка.

Если цикл действительно нужен, можно сохранить стабильные имена модулей внутри каталога с версией всего выпуска. Тогда версия относится ко всему каталогу, а не к байтам отдельного файла. Можно и пересмотреть зависимости, убрав цикл. В обоих случаях меняется устройство сборки или модулей; просто пропустить проверку внутри visit не получится.

Отсутствующая картинка останавливает сборку раньше записи результатов:

const missing = new Map(sources);
missing.delete('/i/mark.svg');

assert.throws(() => bundle(missing), /Missing \/i\/mark.svg, referenced by \/css\/page.css/);

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

Где заканчивается работа сборщика

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

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

Старые файлы также могут понадобиться уже открытой странице. Она получила предыдущий HTML и позже решила загрузить модуль или изображение. Удаление всего старого набора сразу после выпуска может сломать такую загрузку. Политика хранения прежних выпусков относится к доставке и не появляется автоматически вместе с манифестом.

Для самого index.html нужны другие настройки кеширования. Его адрес остаётся прежним, а содержимое обновляется при выпуске, поэтому браузеру нужно периодически проверять страницу заново. Иначе новые адреса ресурсов до него не дойдут.

В этой сборке мне важно, что перед вызовом digest уже готовы все зависимости и заменены все ссылки. Дальше содержимое файла не меняется. Поэтому новая картинка меняет адрес CSS, но не затрагивает независимый скрипт. А повторная сборка тех же исходников оставляет адреса прежними: браузеру незачем скачивать эти файлы заново.