Привет, Хабр! Пилю потихоньку пет‑проекти решил поделиться тем, что вылезло при первом же деплое.
Откуда взялась эта штука
Основная занятость у меня связана с ИИ, но есть ещё подработки по 1С — в основном, администрирование: обновления, настройка оборудования, выгрузки и прочее. Всё взаимодействие идёт в Telegram: множество чатов, в каждом свои задачи, у каждой свой дедлайн и своё техническое окно, когда её вообще можно делать. Базу не обновишь в разгар рабочего дня — значит, задача не просто «к пятнице», а «в пятницу после семи».
Пока задач несколько, они держатся в голове. Дальше начинается путаница: помнишь, что на этой неделе у кого‑то обновление, а у кого именно — уже нет. Я начал забывать про клиентов. Писал на бумажке. Бумажка не работает по простой причине: про неё тоже надо вспомнить. Она лежит рядом и молчит. А напоминание должно приходить само и туда, где я и так сижу целый день. Так и родилась мысль сделать свой блэкджек.
По факту проект — обычная ToDo‑шка, но с парой нюансов: Telegram‑бот плюс десктопное приложение с синхронизацией в обе стороны. Пересылаешь боту сообщение из любого чата и сразу ставишь срок, когда приступать. Переносить руками ничего не нужно — перенос ровно то место, где у меня ломалась любая система. Дальше напоминание приходит в Telegram, а на компьютере всплывает поп‑ап поверх окон: если я в этот момент сижу в конфигураторе, сообщение в чате я просто не увижу. Ну и раз есть доступ к Claude — грех не воспользоваться.
Бот собрался быстро, а вот с приложением пришлось повозиться подольше. Пока всё было на локальной машине, оно работало и радовало. Залил на выделенный сервер, собрал установщик, поставил себе — приложение отказалось подключаться к серверу. Пока разбирался, залез в боевую базу и увидел там ноль привязанных устройств. Ни одного, хотя знакомые обещали потестировать. Баг, из‑за которого ни один установленный экземпляр не мог подключиться, жил с самого первого релиза — просто до сих пор все запуски были из исходников. Ниже — четыре ошибки, которые невозможно увидеть в режиме разработки, и один ошибочный диагноз, который я поставил себе сам и чинил не то.
Стек: Electron и React на клиенте, Fastify и SQLite на сервере, всё в Docker. Три бага из четырёх воспроизводятся в любом десктопном приложении на вебе.
Код писался с ИИ‑ассистентом, использую Claude через плагин в VSCode. Интересно то, что ни один из четырёх багов ассистент не смог поймать сразу: все они проявляются только там, куда модель не заглядывает, — в упакованном билде, в реальной сети, в поведении API, которое не следует из его сигнатуры. Читать код умеет, а собрать установщик, поставить его на чистую машину и потыкать в трей — нет. Граница проходит примерно здесь.
1. «Failed to fetch» без единого запроса в сеть
Авторизация в десктопе сделана по подтверждению кода из бота: экран привязки просит шесть цифр. Ввожу код — «Сервер недоступен: Failed to fetch». Начинаю проверку доступности:
сервер отвечает:
curl http://IP:8080/health→{"ok":true};CORS настроен: preflight отдаёт
204с нужными заголовками;POST /link/redeemс выдуманным кодом →400 bad_code, эндпоинт живой;адрес сервера зашит в бандл правильно — проверил
grepпо собранному файлу.
И при этом в логах сервера тишина, ни одной попытки синхронизации. Не ошибка, не таймаут — запрос не уходил вообще. Гипотез было четыре: сеть, сервер, сборка, окружение Electron. Чтобы отсечь три из них разом, я собрал отдельное приложение из двух файлов — пустое окно и страница по file://, которая делает тот же запрос по тому же адресу.
main.js — только чтобы открыть окно и вывести консоль страницы в терминал:
const { app, BrowserWindow } = require("electron"); app.whenReady().then(async () => { const win = new BrowserWindow({ show: false }); win.webContents.on("console-message", (_e, _l, msg) => process.stdout.write(msg + "\n")); await win.loadFile("probe.html"); setTimeout(() => app.exit(0), 3000); // проба одноразовая: вывел и вышел });
probe.html — сам запрос:
<!doctype html> <meta charset="utf-8"> <script> const BASE = "http://ваш-сервер:8080"; fetch(BASE + "/health") .then((r) => r.text().then((t) => console.log("GET /health →", r.status, t))) .catch((e) => console.log("GET /health ПРОВАЛ →", e.name + ": " + e.message)); fetch(BASE + "/link/redeem", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ secret: "000000", name: "probe" }), }) .then((r) => r.text().then((t) => console.log("POST /link/redeem →", r.status, t))) .catch((e) => console.log("POST ПРОВАЛ →", e.name + ": " + e.message)); </script>
Запускается через npx electron . и печатает результат прямо в терминал. Проба отработала: 200 и 400 bad_code. Сеть, сервер и Electron чисты — значит дело внутри приложения. Разница между пробой и приложением была ровно одна, и Electron сам о ней предупредил в консоли: у пробной страницы не было Content‑Security‑Policy. Смотрю index.html приложения:
<meta http-equiv="Content-Security-Policy" content=" default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* https: ws://localhost:* wss:; ...">
connect-src разрешает обычный http только на localhost. Боевой сервер — внешний адрес без TLS. Chromium отбивает такой запрос внутри страницы, ещё до сети, и fetch отклоняется с TypeError: Failed to fetch — тем же сообщением, что при обрыве связи. Отличить одно от другого по тексту ошибки нельзя. В режиме разработки сервер поднят на localhost, а он в белом списке. Поэтому баг и прожил столько времени. Дополнительно сбивало с толку, что главный процесс под CSP не подпадает: автообновление работало исправно, приложение явно ходило в сеть — просто не той своей половиной.
Починка — открыть connect-src для http и ws:
connect-src 'self' http: https: ws: wss:;
Это ослабление безопасности, но в моём случае адрес сервера берётся из списка и может меняться в рантайме — переезд сервера не должен требовать пересборки клиента, поэтому белый список конкретных хостов не работает в принципе. Всё остальное осталось строгим: скрипты только свои, инлайновых нет. Если у вас адрес сервера фиксирован — перечислите его явно, не открывайте схему целиком.
2. Пустая картинка вместо ошибки: невидимая иконка в трее
Следующее с чем столкнулся: пропала иконка в трее. А поскольку крестик сворачивает окно в трей, а «Выход» живёт в меню этой иконки — приложение стало невыгоняемым. Только диспетчер задач.
Код выглядел безобидно:
const resources = join(app.getAppPath(), "resources"); baseIcon = nativeImage.createFromPath(join(resources, "icon.png")); tray = new Tray(baseIcon);
В разработке app.getAppPath() — это папка проекта, файл на месте. В упакованном билде getAppPath() указывает внутрь app.asar, а resources туда не пакуется: electron‑builder кладёт extraResources рядом с архивом, в <install>/resources/resources/. Главная подлость не в путях, а в поведении API. nativeImage.createFromPath на несуществующий файл не бросает исключение и не возвращает null — он возвращает пустое изображение.new Tray() принимает такую картинку без возражений и создаёт трей честно, просто невидимый. Ошибки нет нигде: ни в консоли, ни в логах.
Проверяется в три строки:
const img = nativeImage.createFromPath("C:/такого/файла/нет/icon.png"); console.log(img === null, img.isEmpty()); // false true — не null, а пустая картинка new Tray(img); // создаётся без ошибки
и
const resources = app.isPackaged ? join(process.resourcesPath, "resources") : join(app.getAppPath(), "resources");
API, которые молча возвращают пустое значение вместо ошибки, стоит оборачивать проверкой существования файла. Особенно если от них зависит единственный способ закрыть приложение.
3. Обновление, скачивающееся в ноль байт
Сначала автообновление я делал руками через GitHub Releases, потом перевел на собственный сервер — раздаю latest.yml и установщики со своего эндпоинта. Приложение находило новую версию, начинало качать, и временный файл вечно оставался нулевого размера. Причин в итоге оказалось две:
Нет content-length. Файл отдавался потоком:
return reply.send(createReadStream(path));
Fastify в таком случае переходит на chunked‑кодирование, размер ответа заранее неизвестен. Апдейтер по такому ответу не может ни показать прогресс, ни проверить, скачал ли файл целиком. Добавил длину — и решил, что победил. Не победил.
Составной Range. Дифференциальная докачка (electron‑updater умеет тянуть только изменившиеся блоки, сверяясь с .blockmap) складывает все нужные куски в один заголовок:
Range: bytes=0-99,200-299,5000-9999
Мой обработчик понимал ровно один диапазон и на составной запрос отдавал обычный 200 со всем файлом. Апдейтер ждёт 206 multipart/byteranges и на любой другой код отвечает ошибкой «Server doesn't support Accept‑Ranges» — то есть обрывается, не записав ни байта. И вот деталь, объясняющая, почему баг появился ровно в момент переезда. У провайдеров github, gitlab и bitbucket составные запросы отключены в самом electron‑updater — там стоит isUseMultipleRangeRequest: false. У generic, то есть у своего сервера, значение по умолчанию обратное: включено. Пока обновления раздавал GitHub, дифф‑докачка ходила одиночными диапазонами и работала. Как только я перевёл её на свой сервер, она переключилась на составные — и упёрлась в мой обработчик. Реализовывать multipart я не стал: это возня с границами и буферами ради экономии трафика на релизах раз в неделю. Отключил дифференциальную докачку одной строкой:
autoUpdater.disableDifferentialDownload = true;
И завёл лог ошибок апдейтера в файл. В упакованном билде консоли нет, и всё это время ошибки уходили в никуда — именно поэтому диагностика заняла два круга вместо одного.
4. Страница, отдающая 401 сама себе
Тот же сервер раздаёт лендинг. Публичные маршруты я описал так:
if (req.url === "/" req.url === "/health" req.url.startsWith("/updates/")) return;
// дальше — проверка токена
Тест поймал то, что иначе обнаружили бы пользователи: / работает, а /?lang=en отдаёт 401. Сравнивается весь URL вместе со строкой запроса, а переключатель языка добавляет параметр. То есть переключатель не сработал бы ни разу.
const path = req.url.split("?")[0];
Мелочь, но показательная: маршрутизация по «строке, которая обычно выглядит как путь» ломается ровно тогда, когда к пути что‑то дописывают.
Ну и моя невнимательность и забывчивость: проверка, которая не могла дать верный ответ
Самая полезная часть, потому что здесь ошибся я, а не библиотека. Сервер перестал отвечать: ни SSH, ни API. ping проходил, все TCP‑порты молчали. Чтобы понять, жив ли процесс бота, я опросил Telegram:
GET /getUpdates?limit=1&timeout=0 → {"ok":true,"result":[]}
Думаю: если бот работает, Telegram ответит 409 Conflict — «уже идёт другой запрос getUpdates». Ответ чистый, значит бот не запущен, значит сервис лежит. Проверка оказалась некорректной, новый запрос getUpdates всегда выигрывает имеет приоритет: Telegram обрывает предыдущий висящий запрос и обслуживает свежий. 409 остаётся тому, кто уже висел, — то есть невидимому для меня чужому экземпляру. Проба физически не могла обнаружить конкурента. Правильная проверка — повесить длинный запрос и посмотреть, оборвут ли его:
GET /getUpdates?limit=1&timeout=25
Через шесть секунд пришёл 409. Конкурент есть. Правда оказалась банальной: старый сервер был жив всё это время и обслуживал бота. Недоступен он был только с моей сети: ICMP проходил, а TCP на все порты — нет. С другой машины, из другого дата‑центра, тот же адрес открывался мгновенно. Где именно резалось — у моего провайдера, на маршруте или в фильтре хостера — я так и не выяснил, но для диагностики это и не понадобилось: хватило второй точки обзора.
Что по итогу:
Упакованный билд — другая среда, а не «то же самое, но в архиве». Пути, политика безопасности, наличие консоли — всё отличается. Проверять надо именно его, и желательно не в день релиза.
Минимальная проба, воспроизводящая спорное место в чистом окружении, отсекает гипотезы быстрее, чем чтение кода. Два коротких файла сэкономили мне несколько часов и сразу отделили «наше приложение» от «Electron вообще».
API, молча возвращающие пустое значение вместо ошибки, дают самые долгие баги: искать нечего, в логах пусто.
Если в приложении негде посмотреть ошибку — заведите файл лога раньше, чем он понадобится.
Ноль в метрике — это тоже сигнал. Ноль привязанных устройств за месяц я должен был заметить куда раньше, чем наткнулся на него случайно.
Спасибо, кто дочитал до конца)

