@kawaki-dev/pack
v0.1.0
Published
Общий контейнер паков Kawaki: zip с манифестом, JSON-документами и медиа по sha256
Maintainers
Readme
@kawaki-dev/pack
Общий контейнер паков Kawaki: zip с manifest.json, JSON-документами и медиа,
лежащими под своим sha256. На нём построены .kwq (паки Kawaki Quiz,
@kawaki-dev/kwq); формат .kws редактора анимации карточек
(студия) тоже будет строиться на этом пакете, а не на копии кода.
Пакет ничего не знает ни о файловой системе, ни о сети: на входе и на выходе байты. Хеш считается через WebCrypto, поэтому один и тот же код работает в Node 18+, в браузере и в WebView (Tauri), а проверка у редактора и сервера заведомо одна.
Контейнер
my.pack zip
├── manifest.json описание пака и опись медиа
├── pack.json документ(ы) формата — любой JSON
└── media/
├── 3f9a…c1.jpg имя файла = sha256 содержимого
└── 8b12…7e.mp3Допустимое имя записи описывается одной регуляркой
^media/[0-9a-f]{64}\.[a-z0-9]{1,4}$ (кроме manifest.json и документов,
которые назвал формат). Отсюда главное свойство: path traversal исчезает как
класс, а не ловится проверками на ... Одинаковые файлы схлопываются в одну
запись.
Читаем в два приёма: сначала опись архива (размеры, имена, коэффициент сжатия), потом содержимое. Zip-бомба отвергается по описи, до распаковки.
Манифест
Базовая схема baseManifestSchema (zod):
| поле | что это |
| --------------- | -------------------------------------------------------------- |
| format | идентификатор формата: "kawaki-quiz", … |
| formatVersion | целое; проверяется числом, чтобы пак «из будущего» получил внятный отказ |
| generator | { name, version } — кто собрал |
| createdAt | ISO-8601 с часовым поясом |
| id | ULID пака, живёт всю его жизнь |
| requires | string[] — механики, без которых пак не открыть |
| meta | свободные поля формата (необязательно) |
| media | { id, mime, size }[] — опись файлов |
Формат расширяет схему: baseManifestSchema.extend({ title: z.string() }) —
и передаёт её в readContainer / writeContainer. Формат, у которого манифест
исторически устроен иначе (как у .kwq: packId, generator.app), пишет свою
схему целиком; от неё требуется только format, formatVersion и media
(тип ManifestLike).
Запись
import { writeContainer } from "@kawaki-dev/pack";
const { bytes, manifest } = await writeContainer({
format: "kawaki-studio",
formatVersion: 1,
generator: { name: "kawaki-studio", version: "0.3.0" },
documents: { "pack.json": scene }, // любой JSON
media: [{ bytes: png }, { bytes: mp3, mime: "audio/mpeg" }],
meta: { title: "Карточка" }, // необязательно
requires: ["feature:mesh"], // необязательно
});mimeможно не указывать: тип определяется по содержимому (неоднозначный mp4 считается видео; для m4a укажитеaudio/mp4).- Медиа складывается без сжатия (jpeg, mp3, mp4 уже сжаты), json — со сжатием.
Для типов формата, которые сжимаются, у правила есть
compress: true. - В конце
writeContainerчитает собранное своим читателем, поэтому сломанный редактор не выпустит пак, который сервер отвергнет после стомегабайтной заливки. Ошибка —PackErrorсо списком проблем. - Своя схема манифеста:
manifest: { schema, extra: { ...поля } }.
Для формата со своим манифестом есть два уровня ниже:
prepareMedia(items) (хеши, типы, дедупликация) и
assembleContainer({ manifest, documents, media }) (только упаковка в zip).
Чтение
import { readContainer, openContainer } from "@kawaki-dev/pack";
const result = await readContainer(bytes, {
expectFormat: "kawaki-studio", // другой формат → PACK_FORMAT_UNKNOWN
supportedVersion: 1, // другая версия → PACK_UNSUPPORTED_VERSION
manifestSchema, // по умолчанию baseManifestSchema
documents: ["pack.json"], // какие JSON-записи ожидаем
limits, // см. ниже
mime, // таблица типов формата
});
// result: { ok, issues, container: { manifest, documents, media } | null }openContainer то же самое, но бросает PackError. container.media — это
Map<id, { bytes, mime }>, хеш и расширение каждого файла уже проверены.
container заполнен, когда разбор дошёл до конца; при ok: false пользоваться
им нельзя.
Чтение никогда не бросает на первой же ошибке: автору нужно увидеть все проблемы сразу. Исключение — битый архив, дальше которого идти некуда.
Опись без распаковки: listEntries(bytes) — имена, размеры и способ сжатия
записей, ни одна запись не распаковывается (годится для показа полки и для
предварительной проверки на сервере).
Хуки формата в readContainer:
documents: [{ name, missingCode, invalidJsonCode, label }]— свои коды проблем для документов (у.kwqэтоBOARD_MISSINGи т. п.);checkDocuments({ manifest, documents })— разбор документов формата сразу после манифеста и до распаковки медиа; может прервать чтение (abort);checkMedia: false— не проверять опись медиа самому, если формат делает это вместе с остальным содержимым черезvalidateMediaEntries.
Пределы
ContainerLimits — один объект на редактор и на сервер (иначе редактор считает
пак готовым, а сервер отвергает его после заливки):
| поле | по умолчанию | смысл |
| ------------------ | ------------ | ------------------------------------------------ |
| archiveBytes | 200 МБ | сам файл |
| unpackedBytes | 400 МБ | сумма распакованного, считается по описи |
| entries | 1000 | записей в архиве |
| compressionRatio | 100 | во сколько раз запись может раздуться (zip-бомба) |
| media | image 10 МБ, audio 20 МБ, video 100 МБ | предел одного файла по виду |
Формат добавляет свои пределы расширением интерфейса
(interface QuizLimits extends ContainerLimits { rounds: number; … }) и
подставляет объект в limits. Неизвестный вид медиа без своего предела в
media ограничен unpackedBytes.
Типы медиа
ALLOWED_MIME — белый список: jpeg, png, webp, mp3, m4a, ogg, mp4, webm.
Заявленному mime и расширению не верим: sniffFamily(bytes) читает сигнатуру.
SVG в списке нет намеренно — это XML со скриптами, а пак — данные.
Формат разрешает больше через mimeTable:
const mime = mimeTable({
"image/vnd.adobe.photoshop": {
kind: "psd", // ключ в limits.media
families: [],
ext: "psd", // 1–4 символа a-z0-9
sniff: (b) => b[0] === 0x38 && b[1] === 0x42 && b[2] === 0x50 && b[3] === 0x53,
compress: true,
},
"application/json": { kind: "data", families: [], ext: "json", sniff: (b) => b[0] === 0x7b, compress: true },
});
const limits = { ...DEFAULT_CONTAINER_LIMITS, media: { ...DEFAULT_CONTAINER_LIMITS.media, psd: 50 * MB, data: MB } };Проблемы (Issue)
{ code, severity: "error" | "warning", message, path? }. code — для кода
(сервер отдаёт клиенту, редактор ведёт к полю), message — для человека, всегда
по-русски. Коды контейнера — PACK_ISSUE_CODES (ZIP_*, LIMIT_*,
MANIFEST_*, DOCUMENT_*, PACK_FORMAT_UNKNOWN, PACK_UNSUPPORTED_VERSION,
MEDIA_*). Формат дописывает свои: Issue<"MY_CODE" | PackIssueCode>,
error("MY_CODE", "…", path). Для перевода ошибок zod на русский —
zodIssues(err, code, prefix, hints).
Брошенная ошибка — PackError { issues } (в @kawaki-dev/kwq она же
KwqError).
Как строится формат: @kawaki-dev/kwq
.kwq берёт у пакета всё контейнерное и добавляет своё:
- схемы доски, вопросов и носителей (zod) и реестр типов вопросов;
validateContent— ссылки на медиа, механики, лимиты раундов/тем/вопросов;- коды
BOARD_*,QUESTION_*,LIMIT_ROUNDSи т. д.; limitsрасширяютContainerLimits;readPack=readContainerсexpectFormat: "kawaki-quiz", документомpack.json,checkDocuments(разбор доски) иcheckMedia: false;writePack=assembleContainerсо своим манифестом и проверка собранного собственнымvalidatePack.
Формат студии (.kws) устроится так же: свой format, схемы документов
сцены, расширение baseManifestSchema, таблица типов с PSD и JSON, свои
пределы.
Разработка
npm test # typecheck + vitest
npm run build # dist/