0066. npm-реестр для Unity Package Manager
Статус: accepted. Owner: Ian Panaev. Дата: 2026-10-04. Третий протокол рядом с /api/v1 после реестра образов (ADR 0063) и Git LFS (ADR 0065); хранение и права — прежние.
Контекст
Unity подключает пакеты из «scoped registries» — серверов с протоколом npm. Студии держат в них свои SDK, инструменты и общие модули, которые проекты подтягивают по версиям. Unity требует от такого сервера метаданные пакета, tarball и поиск (/-/v1/search или /-/all); имена со @scope Unity не поддерживает. Публикуют пакеты обычным npm publish. Тот же протокол нужен для JavaScript-инструментов сборки, поэтому реестр npm-совместимый, а не только «для Unity».
Решение
Адрес. Реестр репозитория — https://<host>/npm/<repository>/. В Unity это url scoped registry в Packages/manifest.json, в npm — registry или @scope:registry в .npmrc.
Протокол — подмножество npm registry API:
GET <name>— packument. Каждая версия — еёpackage.jsonсname,version,_idиdistреестра (tarball,shasumSHA-1,integritySHA-512).GET <name>/-/<file>.tgz— tarball с Range, через общую очередь и ограничение полосы.PUT <name>—npm publish: одна версия и её tarball в base64.GET /-/v1/search?text=&from=&size=— поиск по части имени или описания и по ключевому слову. В ответе по одной строке на пакет: версияlatest, иначе новейшая.GET|PUT|DELETE /-/package/<name>/dist-tags[/<tag>]—npm dist-tag.latestудалить нельзя.- Имена — правила npm (строчные, до 214 символов,
@scope/допустим для npm-клиентов). Версии — SemVer 2.0. Тег не может выглядеть как версия. - Unpublish, deprecate,
npm loginи/-/allне поддерживаются. Изменения по этим путям получают405. - Ошибки —
{error, code, request_id}: npm печатаетerrorпосле строки статуса. - Маршрут
/npm/:repository/*следует протоколу npm и в OpenAPI/api/v1не входит.
Публикация потоком. Тело npm publish — JSON с tarball в base64, а пакеты Unity бывают по сотням мегабайт. Сервер разбирает тело потоком:
- строка
_attachments.<file>.dataдекодируется из base64 сразу в staging raw-файлов (ADR 0064); - остальной документ держится в памяти до 8 МиБ;
- второй tarball, экранирование или ошибка base64 отклоняются.
Затем tarball читается заново:
- считаются SHA-1, SHA-256 и SHA-512 всех байтов;
- из gzip+tar извлекается
<каталог>/package.json, как это делает npm. Распаковка останавливается на нём; zlib работает вне event loop и с backpressure.
Манифест версии — package.json из tarball, а не JSON клиента. Имя и версия в нём обязаны совпасть с публикацией. Длина, shasum и integrity, которые объявил клиент, сверяются с полученными байтами (integrity_mismatch). Зеркало выводит ту же строку из тех же байтов.
Неизменяемость.
- Опубликованная версия не меняется.
- Тот же tarball повторно — успех без изменений: так повтор после обрыва безопасен. Сам npm до отправки отказывает, если версия уже есть.
- Другое содержимое той же версии —
409 version_exists. - Tarball — артефакт с меткой
npmи ключом идемпотентности по SHA-256. - Строку версии заменяет только новая публикация, если её артефакт удалён администратором.
Вход. Ключ Arkvory как токен: Bearer из _authToken npm или token в .upmconfig.toml Unity. Также принимается Basic (_auth) с ключом как паролем. Ответ 401 не содержит вызова: клиенты шлют настроенный токен сами.
Права:
- packument и tarball —
content.read; - поиск и список тегов —
artifact.list; - публикация и изменение тегов —
upload.create(файловые ключи —write); - токен только для чтения и зеркало ничего не меняют;
- шлюзы чтения (ADR 0009) обслуживают установку: все чтения — GET.
Хранение.
- Миграция 32 (только расширение схемы):
arkvory_npm_versions(repository, name, version → artifact, file, manifest, description, keywords, shasum, integrity, published_at)иarkvory_npm_tags(repository, name, tag → version). - Манифест хранится как текст:
jsonbотвергает часть допустимых строк JSON. - Чтения пропускают версии, артефакт которых недоступен.
- Обе таблицы входят в резервные копии.
- Retention не удаляет tarball версии (причина
reference).
Зеркала. В ленту пишутся npm.version ({name, version}), npm.tag ({name, tag, version}) и npm.tag.delete ({name, tag}). Запись идёт в транзакции изменения, под замком каталога репозитория. Зеркало:
- копирует артефакт;
- выводит версию из его
package.jsonи хешей; - применяет теги по имени, пропуская неизвестные версии;
- после засева проигрывает эти записи, как записи реестра образов.
Последствия
- Удаления версий нет: потребители закрепляют версии в lock-файлах, и удаление ломает сборки. Неверную версию заменяет следующая.
- Прокси к npmjs.org и upstream-реестрам нет: пакеты из публичного реестра Unity и npm берутся оттуда напрямую, а Arkvory хранит свои.
- Поиск — подстрочный, без ранжирования по популярности.
timeв packument зеркала — время применения на зеркале.
Проверки
tests/npm-packages.test.mjs:- разбор тела при любом размере кусков;
- padding на границе кусков, экранированные ключи;
- отказы: второй tarball, неверный base64, превышение документа, неверный JSON;
- tar с pax-путём и пропуском больших записей, вложенный и отсутствующий
package.json, повреждённый заголовок; - правила публикации, integrity, packument и деталей ленты.
tests/integration/npm-registry.test.mjsс настоящим npm:publish, публикация с--tag beta, отказ npm на повтор;- на уровне сервера: повтор того же tarball — 200, другое содержимое — 409, неверный
shasum—integrity_mismatch; dist-tag add/rm/ls,installс проверкойintegrityи сверкой файла,search;- поиск и packument так, как их читает Unity (
unity,displayName); - пакет
@team/utilс%2f; - 401 и 404 в формате npm;
- ключ только для чтения:
installработает,publishиdist-tag— 403; unpublish — 405.
tests/integration/mirror-images.test.mjs: версии до и после засева, перенос и удаление тега, tarball с зеркала поdist.tarball, публикация в зеркало отклоняется.tests/mirror-sync.test.mjs: проигрышnpm.*и пропуск неверных деталей.- Настоящий клиент Unity в CI не запускается: его запросы (packument, tarball,
/-/v1/search) проверены через HTTP по описанию Unity.
Дополнение: проверка перед 0.4.0
- Tarball читается до конца. npm при извлечении оставляет последний файл пути, поэтому второй
package.json(в том числеx/./package.json) или ссылка на его месте — отказ. Иначе реестр проверял бы один манифест, а клиенты ставили бы другой. pax-заголовок больше 64 КиБ — отказ, а не пропуск. - Архив, который распаковывается больше чем в 100 раз сверх своего размера плюс 64 МиБ (gzip-бомба), отклоняется. Проверка останавливается вместе с запросом.
- JSON тела публикации — не глубже 64 уровней.
package.json— до 256 КиБ: packument отдаёт манифесты всех версий одним ответом. - Если загрузка под ключом
npm-<sha256>истекла или отменена, публикация идёт под новым ключом. - Адреса tarball строятся так же, как ссылки Git LFS (ADR 0065):
X-Forwarded-Protoтолько повышает схему. - При гонке двух публикаций одной версии с разным содержимым проигравший артефакт остаётся без ссылок, и его удаляет retention.