Skip to content

Vaynerov Technologies

Мы не просто разрабатываем — мы создаём каждую строку кода и пиксель.

Все статьиРазборы

Двадцать пять инструментов, ноль загрузок

Чип на хабе Toolbox гласит 100% CLIENT-SIDE · NOTHING IS UPLOADED. Это пять слов маркетинга и около тридцати тысяч строк последствий — потому что, как только вы отказываетесь отправлять байты на сервер, системную работу приходится делать браузеру.

Vaynerov TechnologiesСтудия
Опубликовано 11 мин чтения
На этой странице
  1. Один RPC-контракт, восемь воркеров
  2. Плоская память, файлы любого размера
  3. Потоковый SHA-256 без BigInt
  4. Не сохраняется ничего, кроме настроек
  5. Злоумышленник — ваш собственный буфер обмена
  6. Кодировщики, написанные своими руками
  7. Двадцать пять карточек, которые не дышат в такт

Пообещать, что файл никогда не покинет устройство, дёшево. Сдержать обещание — нет. Сайт инструментов с сервером посчитает хеш трёхгигабайтного ISO четырьмя строками на Node и машиной с настоящей памятью. У Toolbox нет ни того, ни другого: у него одна вкладка, куча, которую браузер вправе убить когда угодно, и главный поток, которому ещё и ваш курсор двигать. Всё, что сделал бы сервер, пришлось пересобрать внутри этой вкладки — потоковое сжатие, инкрементальное хеширование, управление жизненным циклом воркеров, кодировщик QR, цветовой конвейер с отображением в гамут — и всё это должно было переживать файлы крупнее, чем доступная под них память.

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

25
инструментов, четыре категории
8
выделенных Web Workers
39,246
строк в коммите релиза
0
сетевых запросов в поверхности инструментов

Toolbox вышел 25.07.2026; с тех пор поверхность инструментов проверена грепом на сетевые вызовы.

Один RPC-контракт, восемь воркеров

Восемь инструментов делают работу, достаточно тяжёлую, чтобы ей понадобился свой поток: gzip, ZIP, конвертация изображений, хеширование файлов, диф текста, Base64, тестер регулярных выражений и извлечение палитры. Восемь самодельных разговоров через postMessage были бы восемью наборами одних и тех же багов, поэтому все они стоят на одном модуле в 149 строк — типизированном RPC-клиенте для Web Worker с намеренно маленькой поверхностью.

export function createWorkerClient<
  Req extends object, // NOT { id?: number } — that bound would trip
  Res extends { id: number }, // TypeScript's weak-type check for request unions
>(factory: () => Worker): {
  call(request: Req, opts?: { transfer?: Transferable[]; onProgress?: OnProgress }): Promise<Res>;
  terminate(): void;
};

Весь контракт без тела. Вызывающий никогда не пишет id — клиент подставляет его сам и по нему же маршрутизирует ответы.

Вся ценность здесь в правилах маршрутизации. Ответ с { id, progress } уходит в onProgress без разрешения промиса, так что двухминутный архив может отчитаться о себе сотню раз за один вызов. Ответ с { id, error } отклоняет ровно этот вызов. Жёсткая ошибка воркера — поток умер, чанк не загрузился — отклоняет все висящие вызовы и выбрасывает воркер; следующий call() тихо поднимает его заново через переданную фабрику. Воркеры к тому же создаются лениво, при первом обращении, так что открыть ZIP-инструмент не стоит ничего, пока вы не бросите на него файл.

Кадры прогресса не разрешают промис никогда. Мёртвый воркер отклоняет всё, что в полёте, и заменяется при следующем использовании — отмена и восстановление после падения это один и тот же путь в коде.

Последнее свойство даёт отмену бесплатно. Никакого кооперативного флага прерывания, продетого через цикл сжатия: terminate() убивает поток посреди чанка, а следующий запрос строит новый. Сильнее всех на это опирается тестер регулярных выражений, потому что тестер регулярок — машина для запуска пользовательских программ неограниченной стоимости. Каждый прогон получает одноразовый клиент и дедлайн в 600 мс; по истечении мы убиваем поток, сообщаем исход как timeout, а защёлка settled глотает отказ, который потом прилетает от покойника. В полёте всегда только один шаблон — новое нажатие клавиши перезаписывает стоящий в очереди, а не встаёт в хвост, — и cancel() сбрасывает очередь до завершения потока, иначе задача из очереди подняла бы воркер для компонента, который уже размонтирован.

Две конвенции из того же файла состарились лучше, чем мы ожидали. Ошибки пересекают границу устойчивыми кодами (zip:*, failed), а не прозой, поэтому интерфейс переводит их на шесть языков вместо того, чтобы показывать португальскому читателю английское исключение. И половинки на стороне воркера типизированы руками: в нашем tsconfig подключена библиотека DOM, а не webworker, поэтому внутри воркера self — это Window. Вместо драки с конфигом каждый воркер пишет const scope = self as unknown as DedicatedWorkerScope и объявляет те два члена, которыми реально пользуется, — приём, впервые применённый нами в пропагаторе Orrery, о котором мы ни разу не пожалели.

Плоская память, файлы любого размера

CompressionStream и DecompressionStream — причина, по которой gzip-инструмент вообще может жить во вкладке: gzip и deflate, нативно, потоково, без WASM-нагрузки. Ловушка в том, что делать с выходом. Очевидный ход — собрать чанки, склеить в один Uint8Array, обернуть в Blob — удваивает пиковую память в самый неподходящий момент.

Распаковка 2 ГБ потребовала бы 2 ГБ дважды. Конструктор Blob сшивает части без этого пика.

Поэтому общая функция слива собирает BlobPart[] и прямо в файле задокументирована как никогда их не склеивающая. Писатель ZIP идёт дальше и сбрасывает накопленные части каждые 16 МиБ по ходу DEFLATE, потому что хранилищем Blob управляет браузер и выгружает его на диск: куча остаётся плоской, пока архив растёт дальше всего, что вкладка могла бы удержать. Извлечение работает в обратную сторону: File.slice() даёт произвольный доступ в центральный каталог, поэтому архив на 4000 записей перечисляется без чтения его основной массы, а каждая строка владеет внешним хранилищем, а не куском общего состояния React, потому что список, перерисовывающийся по три раза на запись, — не тот список, который вы хотите видеть на телефоне.

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

Прогресс троттлится у источника, а не в React — шаги по 1% для хеширования, 80 мс для ZIP — и меряется тождественным TransformStream, который считает байты на входе конвейера. По сжатому выходу полоска прыгала бы и застревала в зависимости от того, насколько файл сжимаем; входные байты только растут.

Выше 4 ГиБ писатель ZIP перестаёт стараться. DEFLATE на файле такого размера даёт мало, а рискует всем, поэтому за порогом MAX_DEFLATE_BYTES писатель продолжает считать CRC-32 — с лениво построенной таблицей на 256 записей, полином 0xEDB88320, инкрементально по чанкам — и выдаёт запись STORE, переиспользующую исходный File вообще без копий. Есть и нижняя граница браузеров: Firefox 111 и 112 проходят нашу базовую поддержку, но старше CompressionStream (113), поэтому эти пользователи получают работающий ZIP-инструмент, который хранит, а не сжимает, и уведомление с объяснением почему.

// TS 5.9 types the codec's writable as WritableStream<BufferSource>, which
// pipeThrough rejects against Uint8Array<ArrayBuffer> chunks even though a
// Uint8Array IS a BufferSource. Narrowing the pair is safe.

gzip-core.ts. Вторая половина того же семейства: crypto.subtle хочет именно Uint8Array<ArrayBuffer> — ArrayBufferLike к BufferSource не присваивается.

Потоковый SHA-256 без BigInt

crypto.subtle.digest быстр, нативен и конституционально одноразов: он берёт буфер, а не поток. Для файлов, которые вы можете позволить себе удержать, это прекрасно и лучше всего, что написали бы мы. Поэтому у файлового хешера два режима с жёсткой границей между ними на 256 МиБ.

Ниже границы он буферизует один раз и считает все четыре дайджеста сразу — SHA-1, SHA-256, SHA-384, SHA-512, — что превращает чипы алгоритмов в интерфейсе в чистые фильтры отображения. Переключение с SHA-256 на SHA-512 перерисовывает строку, а не перечитывает ваш файл. Выше границы буферизовать как раз нельзя, поэтому инструмент переключается на самописный инкрементальный SHA-256, которому скармливает File.stream(): плоская память и никакого потолка.

Написать SHA-256 — обряд посвящения; написать такой, который переживёт ревью, работы чуть больше. Наш соответствует FIPS 180-4 буквально, включая ту часть, в которой ошибаются все: длина сообщения дописывается 64-битным целым в big-endian, а числа JavaScript такое точно не удержат. Вместо того чтобы тащить BigInt на горячий путь, длина несётся двумя 32-битными словами и инкрементируется с переносом. Он проверен свойствами против crypto.subtle на каждой значимой границе набивки (0, 55, 56, 63, 64, 65 байт — именно на двух случаях с 56 байтами наивные реализации теряют блок) и ещё раз на 1 МБ + 7, чтобы доказать, что потоковый и одноразовый пути сходятся на входах, лежащих поперёк границ чанков.

Не сохраняется ничего, кроме настроек

«Только локально» автоматически не означает «приватно». localStorage локален, и инструмент, который запомнил бы ваш последний сгенерированный пароль, был бы худшей обузой, чем тот, который куда-то его отправил: сервер хотя бы забывает. Поэтому у модуля хранилища есть однострочный контракт, записанный над форматом ключа: только настройки, никогда полезная нагрузка, никогда сгенерированные секреты.

// vaynerov:tools:<slug>:v1 — user OPTIONS only.
// Never persist user payloads or generated secrets.

// Uniform pick without modulo bias: reject the ragged tail of the range.
const limit = Math.floor(0x100000000 / alphabet.length) * alphabet.length;
// never a bare % n, never Math.random

Генератор паролей держит значения в состоянии React — не в localStorage, не в URL, не в аналитике — и берёт их из crypto.getRandomValues с отбраковкой лишнего хвоста диапазона.

Каждое чтение и каждая запись обёрнуты в try/catch, потому что режимы приватного просмотра бросают исключение при доступе, а не вежливо отказывают, — а слой настроек не имеет права ронять инструмент. Извлекатель палитры отказывается от сохранения целиком: его вход — одна из ваших фотографий.

У выдачи результатов наружу свой фольклор. Object URL отзываются через десять секунд после клика, а не синхронно: немедленный отзыв даёт в Safari молчаливые скачивания в ноль байт, а это самый бесящий класс багов, потому что нигде ничего не падает. Множественные скачивания разносятся на 250 мс, чтобы браузерные эвристики попапов не съели четвёртую и последующие записи, — и без лишней паузы после последней.

Злоумышленник — ваш собственный буфер обмена

Отказ от сервера означает и отказ от серверного санитайзера. Предпросмотр markdown рендерит произвольный вставленный текст прямо в страницу, на которую вы смотрите, на каждом нажатии клавиши, и ваша сессия тут же рядом — поэтому он написан так, будто вход враждебен, ведь иногда он таков и есть (README из репозитория, которого вы не читали, — недоверенный ввод).

Каждый текстовый фрагмент экранируется до склейки, а не после сборки, что убирает целое семейство багов, где проход экранирования проезжает по разметке, которую сам же и породил. URL перепроверяются после одного круга декодирования HTML-сущностей — &#106;avascript: это javascript:-URL в шляпе, — а ссылка, не прошедшая вторую проверку, понижается до обычного текста, а не исчезает, чтобы пользователь видел то, что набрал. Само декодирование сущностей — ветка токенизатора на крайний случай, а не шаг предобработки, ровно потому, что предобработка вручила бы парсеру текст, который он никогда не проверял.

Дальше стоимость. Всему, что живёт на пути нажатия клавиши, нужен бюджет, и у предпросмотра их четыре.

БюджетПределЗачем он нужен
MAX_DELIMS5000 символовРабота с разделителями выделения сверхлинейна; считается в символах, а не в узлах
MAX_LINK_SCAN200 000 символовПересканы непарных скобок, оплачиваются только при неудачном скане
MAX_TABLE_CELLS20 000 ячеекОдна вставленная таблица — один ограниченный DOM
MAX_BLOCK_DEPTH32 уровняВложенные цитаты и списки не дотянутся до стека

Четыре бюджета нажатия клавиши в предпросмотре markdown. В XSS-наборе, охраняющем тот же файл, 116 векторов.

Кодировщики, написанные своими руками

Два инструмента везут алгоритмы, а не обёртки, по неромантичной причине: библиотеки, которые мы взяли бы, тяжелее самого инструмента.

Генератор QR — полноценный кодировщик ISO/IEC 18004 model 2, покрывающий версии с 1 по 11: коррекция ошибок Рида—Соломона над GF(256) со стандартным примитивным полиномом 0x11D, разбиение на блоки и перемежение, зигзагообразная расстановка модулей, пропускающая вертикальный столбец синхронизации в колонке 6, все восемь масок сгенерированы и оценены точными четырьмя штрафными правилами спецификации, информация о формате как BCH(15,5) в XOR с 0x5412 и информация о версии как BCH(18,6) начиная с седьмой версии. Ничто из этого не обсуждается: QR-код, который почти правильный, считывается вашим телефоном и не считывается тем, что стоит на кассе. Мы сверили кодовые слова с опубликованным примером HELLO WORLD версии 1-Q, потом написали декодер и прогнали туда-обратно версии 1–9 в трёх режимах кодирования и на всех четырёх уровнях коррекции, ровно на предельной ёмкости.

Извлекатель палитры — медианное сечение с одним намеренным отступлением. Учебниковое медианное сечение режет цветовой ящик по медианному индексу занятых корзин, и на фотографиях это нормально. На логотипах это катастрофа: дайте ему изображение на 90% белое и на 10% чёрное — медианный индекс попадёт внутрь доминирующей плоской серии, обе половины вернутся почти белыми, а чёрный палитра потеряет вовсе. Наш находит самый широкий канал, а потом режет по самому широкому промежутку между занятыми корзинами на нём, разрешая ничьи в сторону медианы по населённости. Он сэмплирует примерно 100×100 и отчитывается в OKLCH — на том же языке, на котором говорит конвертер цветов.

QR-код, который почти правильный, считывается вашим телефоном и не считывается тем, что стоит на кассе.

Двадцать пять карточек, которые не дышат в такт

У хаба была проблема дизайна, которая на самом деле проблема рендеринга: двадцать пять карточек, четыре категории и никакого места под двадцать пять иллюстраций. У каждой категории свой тон в голом HSL — файлы 35 92% 60%, текст 265 72% 70%, код 210 90% 62%, дизайн 325 85% 66%, и все четыре берут 4,5:1 на обоих наших фонах, — и хост инструмента ставит его как --tool-hue на свой контейнер, после чего заголовки, чипы и водяной знак иконки подкрашиваются сами, без единого условия.

Графика карточек — четыре CSS-3D-диорамы, по одной на категорию, с сидом на каждый инструмент. Не WebGL, а трансформы и градиенты, и сид тут важнее геометрии: 32-битный хеш FNV-1a от слага крутит xorshift-генератор, выдающий единичные float, поэтому Math.random и Date.now не встречаются на пути рендера нигде. По правилам чистоты React компонент, который кидает кости во время рендера, — это компонент, который мерцает; компонент, который хеширует собственный слаг, — чистая функция, лишь выглядящая импровизацией. Каждая карточка получает из того же сида множитель скорости ±15% и сдвиг фазы, и ровно поэтому сетка не пульсирует, как сердце.

Этот слой держат ещё два контракта. Движение встаёт за пределами экрана — useInView с полем в 240 px ставит data-paused, который замораживает сцены через animation-play-state, а не размонтирует их, — а наклон карточки идёт на motion values из framer с нулём setState на каждое движение мыши и на касании не включается вовсе. А при prefers-reduced-motion статический трансформ каждого элемента прописан как его собственная собранная поза покоя, и стоп-кадр читается как намеренная диорама, а не как сломанная анимация, застигнутая на полушаге. Это 371 строка CSS ради того, чтобы четыре сцены правильно стояли на месте.

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

File System Access API нет, поэтому результат уходит скачиванием, а не записью обратно на ваш диск. WASM нет, поэтому мы живём внутри того, что даёт платформа, — отсюда и ZIP только в режиме STORE для Firefox 111, и хранение вместо сжатия для архива в 5 ГиБ. Файлы всё равно должны пролезть через вкладку, а вкладка принадлежит браузеру, и он вправе её забрать. Через день после запуска мы натравили на эту же поверхность восемь въедливых ревьюеров, и они нашли тридцать подтверждённых дефектов, которые мы разобрали целиком, а не тихо починили.